Fixed visual issues with new dashboard and added folder attribution

This commit is contained in:
Richard Osborne
2025-12-31 21:40:47 +01:00
parent 73b5a42122
commit 2e46ab7ea7
41 changed files with 6481 additions and 1619 deletions

View File

@@ -1,49 +1,31 @@
import classNames from 'classnames';
import React, {
CSSProperties,
MouseEvent,
MouseEventHandler,
SyntheticEvent,
} from 'react';
import css from './LegacyIconButton.module.scss';
export enum LegacyIconButtonIcon {
VerticalDots = 'vertical-dots',
Close = 'close',
CloseDark = 'close-dark',
CaretDown = 'caret-down',
Generate = 'generate',
}
export interface LegacyIconButtonProps {
icon: LegacyIconButtonIcon;
isRotated180?: boolean;
onClick?: MouseEventHandler<HTMLButtonElement>;
style?: CSSProperties;
testId?: string;
}
export function LegacyIconButton({
icon,
isRotated180,
style,
onClick,
testId,
}: LegacyIconButtonProps) {
return (
<button
className={css['Root']}
onClick={onClick}
style={style}
data-test={testId}
>
<img
className={classNames([
css['Icon'],
isRotated180 && css['is-rotated-180'],
])}
src={`../assets/icons/icon-button/${icon}.svg`}
/>
</button>
);
}
import classNames from 'classnames';
import React, { CSSProperties, MouseEvent, MouseEventHandler, SyntheticEvent } from 'react';
import css from './LegacyIconButton.module.scss';
export enum LegacyIconButtonIcon {
VerticalDots = 'vertical-dots',
Close = 'close',
CloseDark = 'close-dark',
CaretDown = 'caret-down',
Generate = 'generate'
}
export interface LegacyIconButtonProps {
icon: LegacyIconButtonIcon;
isRotated180?: boolean;
onClick?: MouseEventHandler<HTMLButtonElement>;
style?: CSSProperties;
testId?: string;
}
export function LegacyIconButton({ icon, isRotated180, style, onClick, testId }: LegacyIconButtonProps) {
return (
<button className={css['Root']} onClick={onClick} style={style} data-test={testId}>
<img
className={classNames([css['Icon'], isRotated180 && css['is-rotated-180']])}
src={`/assets/icons/icon-button/${icon}.svg`}
/>
</button>
);
}

View File

@@ -103,12 +103,12 @@
left: 0;
visibility: hidden;
height: 0;
overflow: overlay;
overflow: hidden;
white-space: pre;
}
.InputWrapper {
overflow-x: overlay;
overflow-x: hidden;
flex-grow: 1;
padding-top: 1px;
}

View File

@@ -1,38 +1,38 @@
.Root {
}
.Track {
box-sizing: border-box;
height: 24px;
width: 42px;
border-radius: 12px;
background-color: var(--theme-color-bg-1);
cursor: pointer;
display: flex;
align-items: center;
padding: 3px;
margin-left: 8px;
margin-right: 8px;
}
.Indicator {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--theme-color-bg-3);
transition: transform var(--speed-quick) var(--easing-base),
background-color var(--speed-quick) var(--easing-base);
&.is-checked {
transform: translateX(100%);
}
&.is-always-active-color,
&.is-checked {
background-color: var(--theme-color-secondary-bright);
}
}
.Input {
display: none;
}
.Root {
}
.Track {
box-sizing: border-box;
height: 24px;
width: 42px;
border-radius: 12px;
background-color: var(--theme-color-bg-3);
cursor: pointer;
display: flex;
align-items: center;
padding: 3px;
margin-left: 8px;
margin-right: 8px;
border: 1px solid var(--theme-color-border-default);
}
.Indicator {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--theme-color-bg-3);
transition: transform var(--speed-quick) var(--easing-base), background-color var(--speed-quick) var(--easing-base);
&.is-checked {
transform: translateX(100%);
}
&.is-always-active-color,
&.is-checked {
background-color: var(--theme-color-secondary-bright);
}
}
.Input {
display: none;
}

View File

@@ -1,97 +1,105 @@
.Root {
padding: 4px;
max-height: 400px;
overflow: hidden overlay;
&.is-width-small {
width: 140px;
}
&.is-width-default {
width: 200px;
}
&.is-width-medium {
width: 250px;
}
&.is-width-large {
width: 400px;
}
}
.Item {
padding: 8px;
cursor: pointer;
border-radius: 1px;
transition: background-color var(--speed-turbo) var(--easing-base);
&.is-highlighted:not(.is-disabled) {
background-color: var(--theme-color-secondary-highlight);
h2 {
color: var(--theme-color-on-secondary);
}
}
&.is-danger {
.Icon path {
fill: var(--theme-color-danger) !important;
}
}
&.is-disabled {
.Icon path {
fill: var(--theme-color-fg-muted);
}
.Label span {
color: var(--theme-color-fg-muted);
}
}
&:not(.has-component):not(.is-highlighted):not(.is-disabled):hover {
background-color: var(--theme-color-bg-3);
.Label span {
color: var(--theme-color-fg-highlight);
}
&.is-danger .Label span {
color: var(--theme-color-danger);
}
}
}
.Label {
display: flex;
align-items: center;
&.has-bottom-spacing {
padding-bottom: 8px;
}
}
.Icon {
margin: -4px 8px -4px 0;
path {
fill: var(--theme-color-fg-default-contrast);
.Item:not(.has-component):not(.is-disabled):hover & {
fill: var(--theme-color-fg-highlight);
}
}
}
.Divider {
width: calc(100% + 8px);
height: 1px;
background-color: var(--theme-color-bg-3);
margin: 4px -4px;
}
.EndSlot {
display: flex;
align-items: center;
}
.Root {
padding: 4px;
max-height: 400px;
overflow: hidden overlay;
&.is-width-small {
width: 140px;
}
&.is-width-default {
width: 200px;
}
&.is-width-medium {
width: 250px;
}
&.is-width-large {
width: 400px;
}
}
.Item {
padding: 8px;
cursor: pointer;
border-radius: 1px;
transition: background-color var(--speed-turbo) var(--easing-base);
&.is-highlighted:not(.is-disabled) {
background-color: var(--theme-color-primary);
h2 {
color: var(--theme-color-on-primary);
}
.Label span {
color: var(--theme-color-on-primary);
}
.Icon path {
fill: var(--theme-color-on-primary);
}
}
&.is-danger {
.Icon path {
fill: var(--theme-color-danger) !important;
}
}
&.is-disabled {
.Icon path {
fill: var(--theme-color-fg-muted);
}
.Label span {
color: var(--theme-color-fg-muted);
}
}
&:not(.has-component):not(.is-highlighted):not(.is-disabled):hover {
background-color: var(--theme-color-bg-3);
.Label span {
color: var(--theme-color-fg-highlight);
}
&.is-danger .Label span {
color: var(--theme-color-danger);
}
}
}
.Label {
display: flex;
align-items: center;
&.has-bottom-spacing {
padding-bottom: 8px;
}
}
.Icon {
margin: -4px 8px -4px 0;
path {
fill: var(--theme-color-fg-default-contrast);
.Item:not(.has-component):not(.is-disabled):hover & {
fill: var(--theme-color-fg-highlight);
}
}
}
.Divider {
width: calc(100% + 8px);
height: 1px;
background-color: var(--theme-color-bg-3);
margin: 4px -4px;
}
.EndSlot {
display: flex;
align-items: center;
}

View File

@@ -199,6 +199,16 @@ export function Launcher({
}
});
// Folder selection state with localStorage persistence
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(() => {
try {
const stored = localStorage.getItem('launcher:selectedFolderId');
return stored === 'null' ? null : stored;
} catch {
return null; // Default to "All Projects"
}
});
// Persist view mode changes
useEffect(() => {
try {
@@ -219,6 +229,15 @@ export function Launcher({
}
}, [useMockData, projects]);
// Persist folder selection
useEffect(() => {
try {
localStorage.setItem('launcher:selectedFolderId', selectedFolderId === null ? 'null' : selectedFolderId);
} catch (error) {
console.warn('Failed to persist folder selection:', error);
}
}, [selectedFolderId]);
// Determine which projects to use and if toggle should be available
const hasRealProjects = Boolean(projects && projects.length > 0);
const activeProjects = useMockData ? MOCK_PROJECTS : projects || MOCK_PROJECTS;
@@ -264,6 +283,8 @@ export function Launcher({
setUseMockData,
projects: activeProjects,
hasRealProjects,
selectedFolderId,
setSelectedFolderId,
onCreateProject,
onOpenProject,
onLaunchProject,

View File

@@ -26,6 +26,10 @@ export interface LauncherContextValue {
projects: LauncherProjectData[];
hasRealProjects: boolean; // Indicates if real projects were provided to Launcher
// Folder organization
selectedFolderId: string | null;
setSelectedFolderId: (folderId: string | null) => void;
// Project management callbacks
onCreateProject?: () => void;
onOpenProject?: () => void;

View File

@@ -0,0 +1,200 @@
/**
* FolderTree Styles
*/
.Root {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2);
height: 100%;
}
.VirtualFolders {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.Divider {
height: 1px;
background-color: var(--theme-color-border-default);
margin: var(--spacing-2) 0;
}
.UserFolders {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
flex: 1;
overflow-y: auto;
}
.RenameInput {
padding: var(--spacing-1) var(--spacing-2);
}
.RenameInputField {
width: 100%;
padding: var(--spacing-2);
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default);
font-size: var(--theme-font-size-default);
outline: none;
&:focus {
border-color: var(--theme-color-primary);
}
}
.NewFolderButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
margin-bottom: var(--spacing-4);
background: none;
border: 1px dashed var(--theme-color-border-default);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default-shy);
cursor: pointer;
transition: all 0.15s ease;
font-size: var(--theme-font-size-default);
&:hover {
background-color: var(--theme-color-bg-3);
border-color: var(--theme-color-border-highlight);
color: var(--theme-color-fg-default);
}
&:active {
background-color: var(--theme-color-bg-4);
}
}
.NewFolderIcon {
display: flex;
flex-shrink: 0;
}
.NewFolderLabel {
flex: 1;
text-align: left;
}
.CreateFolderInput {
padding: var(--spacing-1) var(--spacing-2);
}
.CreateFolderInputField {
width: 100%;
padding: var(--spacing-2);
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-primary);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default);
font-size: var(--theme-font-size-default);
outline: none;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
}
/* Delete Confirmation Dialog */
.DeleteConfirmation {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.DeleteConfirmationBackdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.DeleteConfirmationDialog {
position: relative;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
min-width: 400px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.DeleteConfirmationTitle {
font-size: 20px;
font-weight: 600;
color: var(--theme-color-fg-default);
margin: 0 0 var(--spacing-3) 0;
line-height: 1.3;
}
.DeleteConfirmationMessage {
font-size: var(--theme-font-size-default);
color: var(--theme-color-fg-default);
margin-bottom: var(--spacing-6);
line-height: 1.5;
}
.DeleteConfirmationButtons {
display: flex;
gap: var(--spacing-3);
justify-content: flex-end;
}
.DeleteConfirmationCancelButton {
padding: var(--spacing-2) var(--spacing-4);
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default);
font-size: var(--theme-font-size-default);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-border-highlight);
}
&:active {
transform: scale(0.98);
}
}
.DeleteConfirmationDeleteButton {
padding: var(--spacing-2) var(--spacing-4);
background-color: var(--theme-color-danger);
border: 1px solid var(--theme-color-danger);
border-radius: var(--radius-default);
color: white;
font-size: var(--theme-font-size-default);
font-weight: var(--theme-font-weight-medium);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-danger-hover);
border-color: var(--theme-color-danger-hover);
}
&:active {
transform: scale(0.98);
}
}

View File

@@ -0,0 +1,276 @@
/**
* FolderTree - Project organization folder tree
*
* Displays virtual folders (All Projects, Uncategorized) and user-created folders
* with expand/collapse functionality and folder management actions.
*
* @module noodl-core-ui/preview/launcher
*/
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { FolderTreeItem } from '@noodl-core-ui/preview/launcher/Launcher/components/FolderTreeItem';
import { Folder, useProjectOrganization } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import css from './FolderTree.module.scss';
export interface FolderTreeProps {
/** Currently selected folder ID (null for "All Projects", 'uncategorized' for uncategorized) */
selectedFolderId: string | null;
/** Called when a folder is selected */
onFolderSelect: (folderId: string | null) => void;
/** Total number of projects */
totalProjectCount: number;
/** Number of projects without a folder */
uncategorizedProjectCount: number;
}
/**
* FolderTree displays the project organization structure:
* - Virtual folders ("All Projects", "Uncategorized")
* - User-created folders with expand/collapse
* - "+ New Folder" button
*/
export function FolderTree({
selectedFolderId,
onFolderSelect,
totalProjectCount,
uncategorizedProjectCount
}: FolderTreeProps) {
const { folders, createFolder, renameFolder, deleteFolder, getProjectCountInFolder } = useProjectOrganization();
// Track expanded folders (folder IDs that are expanded)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(new Set());
// Track folder being renamed
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Track new folder creation
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
// Track folder deletion confirmation
const [deletingFolder, setDeletingFolder] = useState<Folder | null>(null);
const toggleExpand = (folderId: string) => {
setExpandedFolderIds((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
};
const handleCreateFolder = () => {
setIsCreatingFolder(true);
setNewFolderName('');
};
const handleCreateFolderSubmit = () => {
if (newFolderName.trim()) {
createFolder(newFolderName.trim());
}
setIsCreatingFolder(false);
setNewFolderName('');
};
const handleCreateFolderCancel = () => {
setIsCreatingFolder(false);
setNewFolderName('');
};
const handleRenameFolder = (folder: Folder) => {
setRenamingFolderId(folder.id);
setRenameValue(folder.name);
};
const handleRenameSubmit = (folderId: string) => {
if (renameValue.trim()) {
renameFolder(folderId, renameValue.trim());
}
setRenamingFolderId(null);
setRenameValue('');
};
const handleRenameCancel = () => {
setRenamingFolderId(null);
setRenameValue('');
};
const handleDeleteFolder = (folder: Folder) => {
setDeletingFolder(folder);
};
const handleDeleteFolderConfirm = () => {
if (deletingFolder) {
deleteFolder(deletingFolder.id);
// If deleted folder was selected, reset to "All Projects"
if (selectedFolderId === deletingFolder.id) {
onFolderSelect(null);
}
setDeletingFolder(null);
}
};
const handleDeleteFolderCancel = () => {
setDeletingFolder(null);
};
// Organize folders into root and nested
const rootFolders = folders.filter((f) => f.parentId === null);
const getFolderChildren = (parentId: string) => {
return folders.filter((f) => f.parentId === parentId);
};
return (
<div className={css['Root']}>
{/* Virtual Folders */}
<div className={css['VirtualFolders']}>
{/* All Projects */}
<FolderTreeItem
folder={{ id: 'all', name: 'All Projects', parentId: null, order: 0, createdAt: '' }}
projectCount={totalProjectCount}
isSelected={selectedFolderId === null}
onClick={() => onFolderSelect(null)}
/>
{/* Uncategorized */}
<FolderTreeItem
folder={{
id: 'uncategorized',
name: 'Uncategorized',
parentId: null,
order: 1,
createdAt: ''
}}
projectCount={uncategorizedProjectCount}
isSelected={selectedFolderId === 'uncategorized'}
onClick={() => onFolderSelect('uncategorized')}
/>
</div>
{/* Divider */}
{rootFolders.length > 0 && <div className={css['Divider']} />}
{/* User Folders */}
<div className={css['UserFolders']}>
{rootFolders.map((folder) => {
const children = getFolderChildren(folder.id);
const isExpanded = expandedFolderIds.has(folder.id);
const projectCount = getProjectCountInFolder(folder.id);
return (
<div key={folder.id}>
{renamingFolderId === folder.id ? (
<div className={css['RenameInput']}>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameSubmit(folder.id);
} else if (e.key === 'Escape') {
handleRenameCancel();
}
}}
onBlur={() => handleRenameSubmit(folder.id)}
autoFocus
className={css['RenameInputField']}
/>
</div>
) : (
<FolderTreeItem
folder={folder}
projectCount={projectCount}
isSelected={selectedFolderId === folder.id}
hasChildren={children.length > 0}
isExpanded={isExpanded}
onClick={() => onFolderSelect(folder.id)}
onToggleExpand={() => toggleExpand(folder.id)}
onRename={() => handleRenameFolder(folder)}
onDelete={() => handleDeleteFolder(folder)}
/>
)}
{/* Render children if expanded */}
{isExpanded &&
children.map((childFolder) => {
const childProjectCount = getProjectCountInFolder(childFolder.id);
return (
<FolderTreeItem
key={childFolder.id}
folder={childFolder}
projectCount={childProjectCount}
isSelected={selectedFolderId === childFolder.id}
level={1}
onClick={() => onFolderSelect(childFolder.id)}
onRename={() => handleRenameFolder(childFolder)}
onDelete={() => handleDeleteFolder(childFolder)}
/>
);
})}
</div>
);
})}
</div>
{/* New Folder Button */}
{isCreatingFolder ? (
<div className={css['CreateFolderInput']}>
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateFolderSubmit();
} else if (e.key === 'Escape') {
handleCreateFolderCancel();
}
}}
onBlur={handleCreateFolderSubmit}
placeholder="Folder name..."
autoFocus
className={css['CreateFolderInputField']}
/>
</div>
) : (
<button className={css['NewFolderButton']} onClick={handleCreateFolder}>
<Icon icon={IconName.Plus} size={IconSize.Small} UNSAFE_className={css['NewFolderIcon']} />
<span className={css['NewFolderLabel']}>New Folder</span>
</button>
)}
{/* Delete Confirmation Overlay */}
{deletingFolder && (
<div className={css['DeleteConfirmation']}>
<div className={css['DeleteConfirmationBackdrop']} onClick={handleDeleteFolderCancel} />
<div className={css['DeleteConfirmationDialog']}>
<div className={css['DeleteConfirmationTitle']}>Delete Folder</div>
<div className={css['DeleteConfirmationMessage']}>
{getProjectCountInFolder(deletingFolder.id) > 0
? `Delete "${deletingFolder.name}"? ${getProjectCountInFolder(
deletingFolder.id
)} project(s) will be moved to Uncategorized.`
: `Delete "${deletingFolder.name}"?`}
</div>
<div className={css['DeleteConfirmationButtons']}>
<button className={css['DeleteConfirmationCancelButton']} onClick={handleDeleteFolderCancel}>
Cancel
</button>
<button className={css['DeleteConfirmationDeleteButton']} onClick={handleDeleteFolderConfirm}>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { FolderTree } from './FolderTree';
export type { FolderTreeProps } from './FolderTree';

View File

@@ -0,0 +1,87 @@
/**
* FolderTreeItem Styles
*/
.Root {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-1-5) var(--spacing-2);
border-radius: var(--radius-default);
cursor: pointer;
transition: background-color 0.15s ease;
min-height: 32px;
user-select: none;
&:hover {
background-color: var(--theme-color-bg-3);
}
&--selected {
background-color: var(--theme-color-bg-4);
&:hover {
background-color: var(--theme-color-bg-4);
}
}
}
.Chevron {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
cursor: pointer;
color: var(--theme-color-fg-default-shy);
transition: color 0.15s ease;
flex-shrink: 0;
&:hover {
color: var(--theme-color-fg-default);
}
}
.ChevronIcon {
display: flex;
}
.FolderIcon {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-fg-default-shy);
flex-shrink: 0;
}
.Icon {
display: flex;
}
.FolderName {
flex: 1;
min-width: 0; /* Allow text truncation */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Badge {
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--spacing-1-5);
background-color: var(--theme-color-bg-3);
border-radius: var(--radius-full);
min-width: 20px;
height: 18px;
flex-shrink: 0;
}
.ContextMenuTrigger {
display: flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}

View File

@@ -0,0 +1,148 @@
/**
* FolderTreeItem - Individual folder row in the folder tree
*
* Displays a folder with icon, name, project count badge, and context menu.
* Supports nested folders with expand/collapse chevron.
*
* @module noodl-core-ui/preview/launcher
*/
import classNames from 'classnames';
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { ContextMenu, ContextMenuProps } from '@noodl-core-ui/components/popups/ContextMenu';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { Folder } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import css from './FolderTreeItem.module.scss';
export interface FolderTreeItemProps {
folder: Folder;
/** Project count in this folder */
projectCount: number;
/** Whether this folder is currently selected for filtering */
isSelected?: boolean;
/** Whether this folder has nested children */
hasChildren?: boolean;
/** Whether children are expanded (only relevant if hasChildren is true) */
isExpanded?: boolean;
/** Indentation level for nested folders (0 = root) */
level?: number;
/** Called when folder is clicked for filtering */
onClick?: () => void;
/** Called when expand/collapse chevron is clicked */
onToggleExpand?: () => void;
/** Called when rename is requested */
onRename?: () => void;
/** Called when delete is requested */
onDelete?: () => void;
}
/**
* FolderTreeItem displays a single folder in the tree with:
* - Folder icon (open/closed based on expansion)
* - Folder name
* - Project count badge
* - Context menu for rename/delete
* - Expand/collapse chevron for nested folders
*/
export function FolderTreeItem({
folder,
projectCount,
isSelected = false,
hasChildren = false,
isExpanded = false,
level = 0,
onClick,
onToggleExpand,
onRename,
onDelete
}: FolderTreeItemProps) {
const [isHovered, setIsHovered] = useState(false);
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleExpand?.();
};
const contextMenuItems: ContextMenuProps['menuItems'] = [
{
label: 'Rename folder',
icon: IconName.PencilLine,
onClick: () => onRename?.()
},
'divider',
{
label: 'Delete folder',
icon: IconName.Trash,
onClick: () => onDelete?.(),
isDangerous: true
}
];
const paddingLeft = 8 + level * 16; // Base padding + indent per level
return (
<div
className={classNames(css['Root'], {
[css['Root--selected']]: isSelected,
[css['Root--hasChildren']]: hasChildren
})}
style={{ paddingLeft: `${paddingLeft}px` }}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Expand/Collapse Chevron */}
{hasChildren && (
<button
className={css['Chevron']}
onClick={handleChevronClick}
aria-label={isExpanded ? 'Collapse folder' : 'Expand folder'}
>
<Icon
icon={isExpanded ? IconName.CaretDown : IconName.CaretRight}
size={IconSize.Small}
UNSAFE_className={css['ChevronIcon']}
/>
</button>
)}
{/* Folder Icon */}
<div className={css['FolderIcon']}>
<Icon
icon={isExpanded ? IconName.FolderOpen : IconName.FolderClosed}
size={IconSize.Default}
UNSAFE_className={css['Icon']}
/>
</div>
{/* Folder Name */}
<Label
size={LabelSize.Default}
variant={isSelected ? TextType.Default : TextType.Shy}
UNSAFE_className={css['FolderName']}
>
{folder.name}
</Label>
{/* Project Count Badge */}
{projectCount > 0 && (
<span className={css['Badge']}>
<Label size={LabelSize.Small} variant={TextType.Shy}>
{String(projectCount)}
</Label>
</span>
)}
{/* Context Menu */}
{isHovered && (
<div className={css['ContextMenuTrigger']} onClick={(e) => e.stopPropagation()}>
<ContextMenu menuItems={contextMenuItems} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { FolderTreeItem } from './FolderTreeItem';
export type { FolderTreeItemProps } from './FolderTreeItem';

View File

@@ -19,6 +19,8 @@ import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { UserBadgeProps, UserBadgeSize } from '@noodl-core-ui/components/user/UserBadge';
import { UserBadgeList } from '@noodl-core-ui/components/user/UserBadgeList';
import { useProjectOrganization } from '../../hooks/useProjectOrganization';
import { TagPill, TagPillSize } from '../TagPill';
import css from './LauncherProjectCard.module.scss';
// FIXME: Use the timeSince function from the editor package when this is moved there
@@ -73,26 +75,31 @@ export interface LauncherProjectData {
export interface LauncherProjectCardProps extends LauncherProjectData {
contextMenuItems: ContextMenuProps[];
onClick?: () => void;
}
export function LauncherProjectCard({
id,
title,
cloudSyncMeta,
localPath,
lastOpened,
pullAmount,
pushAmount,
uncommittedChangesAmount,
imageSrc,
contextMenuItems,
contributors
contributors,
onClick
}: LauncherProjectCardProps) {
const { tags, getProjectMeta } = useProjectOrganization();
// Get project tags
const projectMeta = getProjectMeta(localPath);
const projectTags = projectMeta ? tags.filter((tag) => projectMeta.tagIds.includes(tag.id)) : [];
return (
<Card
background={CardBackground.Bg2}
hoverBackground={CardBackground.Bg3}
onClick={() => alert('FIXME: open project')}
>
<Card background={CardBackground.Bg2} hoverBackground={CardBackground.Bg3} onClick={onClick}>
<Stack direction="row">
<div className={css.Image} style={{ backgroundImage: `url(${imageSrc})` }} />
@@ -102,6 +109,16 @@ export function LauncherProjectCard({
<Title hasBottomSpacing size={TitleSize.Medium}>
{title}
</Title>
{/* Tags */}
{projectTags.length > 0 && (
<HStack hasSpacing={2} UNSAFE_style={{ marginBottom: 'var(--spacing-2)', flexWrap: 'wrap' }}>
{projectTags.map((tag) => (
<TagPill key={tag.id} tag={tag} size={TagPillSize.Small} />
))}
</HStack>
)}
<Label variant={TextType.Shy}>Last opened {timeSince(new Date(lastOpened))} ago</Label>
</div>

View File

@@ -0,0 +1,80 @@
/**
* TagPill Styles
*
* Small colored pill displaying a tag name.
* Uses tag.color for background, white text for readability.
*/
.Root {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
border-radius: 12px;
transition: opacity 0.15s ease;
user-select: none;
flex-shrink: 0;
&.Clickable {
cursor: pointer;
&:hover {
opacity: 0.85;
}
&:active {
opacity: 0.7;
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: 2px;
}
}
}
/* Size Variants */
.Size-small {
padding: 2px 8px;
min-height: 20px;
}
.Size-medium {
padding: 4px 10px;
min-height: 24px;
}
/* Label */
.Label {
color: #ffffff;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
/* Remove Button */
.RemoveButton {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: #ffffff;
opacity: 0.7;
transition: opacity 0.15s ease;
border-radius: 4px;
&:hover {
opacity: 1;
}
&:active {
opacity: 0.6;
}
&:focus-visible {
outline: 1px solid #ffffff;
outline-offset: 1px;
}
}

View File

@@ -0,0 +1,91 @@
/**
* TagPill Component
*
* Displays a tag as a small colored pill/badge.
* Used to show project tags in cards and lists.
*/
import React from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { Tag } from '../../hooks/useProjectOrganization';
import css from './TagPill.module.scss';
export enum TagPillSize {
Small = 'small',
Medium = 'medium'
}
export interface TagPillProps {
/** The tag data to display */
tag: Tag;
/** Size variant */
size?: TagPillSize;
/** Whether to show remove button */
removable?: boolean;
/** Callback when remove button is clicked */
onRemove?: () => void;
/** Callback when pill is clicked */
onClick?: () => void;
/** Custom className */
className?: string;
}
/**
* TagPill - Displays a tag as a colored pill badge
*
* @example
* ```tsx
* <TagPill
* tag={{ id: '1', name: 'Frontend', color: '#3B82F6' }}
* size={TagPillSize.Small}
* />
* ```
*/
export function TagPill({
tag,
size = TagPillSize.Medium,
removable = false,
onRemove,
onClick,
className
}: TagPillProps) {
const handleRemoveClick = (e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
};
const handlePillClick = (e: React.MouseEvent) => {
if (onClick) {
e.stopPropagation();
onClick();
}
};
return (
<div
className={`${css.Root} ${css[`Size-${size}`]} ${onClick ? css.Clickable : ''} ${className || ''}`}
style={{ backgroundColor: tag.color }}
onClick={handlePillClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
<Label size={size === TagPillSize.Small ? LabelSize.Small : LabelSize.Default} UNSAFE_className={css.Label}>
{tag.name}
</Label>
{removable && (
<button
className={css.RemoveButton}
onClick={handleRemoveClick}
aria-label={`Remove ${tag.name} tag`}
type="button"
>
<Icon icon={IconName.Close} size={size === TagPillSize.Small ? IconSize.Tiny : IconSize.Small} />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TagPill, TagPillSize } from './TagPill';
export type { TagPillProps } from './TagPill';

View File

@@ -0,0 +1,35 @@
/**
* TagSelector Styles
*
* UI for selecting/managing tags for a project.
*/
.Root {
padding: var(--spacing-4);
min-width: 240px;
max-width: 320px;
}
/* Tag Item (Checkbox + Pill) */
.TagItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-3);
}
}
/* Checkbox */
.Checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--theme-color-primary);
}

View File

@@ -0,0 +1,142 @@
/**
* TagSelector Component
*
* Allows users to assign/remove tags from a project.
* Displays all available tags with checkboxes and option to create new tags.
*/
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { Tag, useProjectOrganization } from '../../hooks/useProjectOrganization';
import { TagPill, TagPillSize } from '../TagPill';
import css from './TagSelector.module.scss';
export interface TagSelectorProps {
/** Project path to manage tags for */
projectPath: string;
/** Callback when tags are changed */
onTagsChanged?: () => void;
}
/**
* TagSelector - UI for assigning/removing tags from a project
*
* Shows all available tags with checkboxes, and allows creating new tags inline.
*/
export function TagSelector({ projectPath, onTagsChanged }: TagSelectorProps) {
const { tags, getProjectMeta, addTagToProject, removeTagFromProject, createTag } = useProjectOrganization();
const [isCreatingTag, setIsCreatingTag] = useState(false);
const [newTagName, setNewTagName] = useState('');
// Get current project tags
const projectMeta = getProjectMeta(projectPath);
const assignedTagIds = projectMeta?.tagIds || [];
const handleToggleTag = (tagId: string) => {
if (assignedTagIds.includes(tagId)) {
removeTagFromProject(projectPath, tagId);
} else {
addTagToProject(projectPath, tagId);
}
onTagsChanged?.();
};
const handleCreateTag = () => {
if (!newTagName.trim()) return;
const newTag = createTag(newTagName.trim());
addTagToProject(projectPath, newTag.id);
setNewTagName('');
setIsCreatingTag(false);
onTagsChanged?.();
};
const handleCancelCreate = () => {
setNewTagName('');
setIsCreatingTag(false);
};
return (
<div className={css.Root}>
{/* Tag list */}
{tags.length > 0 ? (
<VStack hasSpacing={2}>
{tags.map((tag) => {
const isAssigned = assignedTagIds.includes(tag.id);
return (
<label key={tag.id} className={css.TagItem}>
<input
type="checkbox"
checked={isAssigned}
onChange={() => handleToggleTag(tag.id)}
className={css.Checkbox}
/>
<TagPill tag={tag} size={TagPillSize.Small} />
</label>
);
})}
</VStack>
) : (
<Label variant={TextType.Shy} size={LabelSize.Small}>
No tags yet. Create one below.
</Label>
)}
{/* Create new tag */}
<Box hasTopSpacing={4}>
{!isCreatingTag ? (
<PrimaryButton
label="Create new tag"
icon={IconName.Plus}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setIsCreatingTag(true)}
UNSAFE_style={{ width: '100%' }}
/>
) : (
<VStack hasSpacing={2}>
<TextInput
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name..."
variant={TextInputVariant.Default}
isAutoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateTag();
} else if (e.key === 'Escape') {
handleCancelCreate();
}
}}
/>
<HStack hasSpacing={2}>
<PrimaryButton
label="Create"
size={PrimaryButtonSize.Small}
onClick={handleCreateTag}
isDisabled={!newTagName.trim()}
UNSAFE_style={{ flex: 1 }}
/>
<PrimaryButton
label="Cancel"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleCancelCreate}
UNSAFE_style={{ flex: 1 }}
/>
</HStack>
</VStack>
)}
</Box>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TagSelector } from './TagSelector';
export type { TagSelectorProps } from './TagSelector';

View File

@@ -0,0 +1,354 @@
/**
* useProjectOrganization
*
* React hook for managing project organization (folders and tags).
* Interfaces with ProjectOrganizationService from noodl-editor.
*/
import { useEffect, useMemo, useState } from 'react';
// Import types and service from noodl-editor
// Note: This assumes the service is available in the editor context
// We'll need to expose it through the window or context
declare global {
interface Window {
ProjectOrganizationService?: any;
}
}
export interface Folder {
id: string;
name: string;
parentId: string | null;
order: number;
createdAt: string;
}
export interface Tag {
id: string;
name: string;
color: string;
createdAt: string;
}
export interface ProjectMeta {
folderId: string | null;
tagIds: string[];
customName?: string;
notes?: string;
}
export interface UseProjectOrganizationReturn {
// Data
folders: Folder[];
tags: Tag[];
// Folder operations
createFolder: (name: string, parentId?: string | null) => Folder;
renameFolder: (id: string, name: string) => void;
deleteFolder: (id: string) => void;
getProjectCountInFolder: (folderId: string | null) => number;
// Tag operations
createTag: (name: string, color?: string) => Tag;
renameTag: (id: string, name: string) => void;
changeTagColor: (id: string, color: string) => void;
deleteTag: (id: string) => void;
// Project organization
moveProjectToFolder: (projectPath: string, folderId: string | null) => void;
addTagToProject: (projectPath: string, tagId: string) => void;
removeTagFromProject: (projectPath: string, tagId: string) => void;
getProjectMeta: (projectPath: string) => ProjectMeta | null;
getProjectsInFolder: (folderId: string | null) => string[];
getProjectsWithTag: (tagId: string) => string[];
}
/**
* Hook to manage project organization through folders and tags.
*
* Note: Currently uses localStorage directly as a fallback.
* In production, this should interface with ProjectOrganizationService
* from the Electron main process.
*/
export function useProjectOrganization(): UseProjectOrganizationReturn {
const [folders, setFolders] = useState<Folder[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [, setUpdateTrigger] = useState(0);
// Memoize service to prevent infinite loop - service must be stable across renders
const service = useMemo(() => {
// TODO: In production, get this from window context or inject it
// For now, we'll implement a minimal localStorage version
return createLocalStorageService();
}, []); // Empty deps - create service once
// Subscribe to service events and load initial data
useEffect(() => {
// Initial load
setFolders(service.getFolders());
setTags(service.getTags());
// Subscribe to changes
const handleDataChange = () => {
setFolders(service.getFolders());
setTags(service.getTags());
setUpdateTrigger((prev) => prev + 1);
};
service.on('dataChanged', handleDataChange);
// Cleanup
return () => {
service.off('dataChanged', handleDataChange);
};
}, [service]);
return {
// Data
folders,
tags,
// Folder operations
createFolder: (name, parentId) => service.createFolder(name, parentId),
renameFolder: (id, name) => service.renameFolder(id, name),
deleteFolder: (id) => service.deleteFolder(id),
getProjectCountInFolder: (folderId) => service.getProjectCountInFolder(folderId),
// Tag operations
createTag: (name, color) => service.createTag(name, color),
renameTag: (id, name) => service.renameTag(id, name),
changeTagColor: (id, color) => service.changeTagColor(id, color),
deleteTag: (id) => service.deleteTag(id),
// Project organization
moveProjectToFolder: (projectPath, folderId) => service.moveProjectToFolder(projectPath, folderId),
addTagToProject: (projectPath, tagId) => service.addTagToProject(projectPath, tagId),
removeTagFromProject: (projectPath, tagId) => service.removeTagFromProject(projectPath, tagId),
getProjectMeta: (projectPath) => service.getProjectMeta(projectPath),
getProjectsInFolder: (folderId) => service.getProjectsInFolder(folderId),
getProjectsWithTag: (tagId) => service.getProjectsWithTag(tagId)
};
}
// ============================================================================
// Minimal localStorage-based service for noodl-core-ui
// This allows the hook to work in Storybook and standalone contexts
// ============================================================================
class EventEmitter {
private listeners: Map<string, Array<(data: any) => void>> = new Map();
on(event: string, callback: (data: any) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
off(event: string, callback?: (data: any) => void) {
if (!callback) {
this.listeners.delete(event);
return;
}
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
emit(event: string, data?: any) {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
}
}
function createLocalStorageService() {
const emitter = new EventEmitter();
const storageKey = 'projectOrganization';
interface StorageData {
version: 1;
folders: Folder[];
tags: Tag[];
projectMeta: Record<string, ProjectMeta>;
}
const loadData = (): StorageData => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('[useProjectOrganization] Failed to load data:', error);
}
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
};
let data = loadData();
const saveData = () => {
try {
localStorage.setItem(storageKey, JSON.stringify(data));
emitter.emit('dataChanged', data);
} catch (error) {
console.error('[useProjectOrganization] Failed to save data:', error);
}
};
const generateId = (prefix: string) => {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
const TAG_COLORS = [
'#EF4444',
'#F97316',
'#EAB308',
'#22C55E',
'#06B6D4',
'#3B82F6',
'#8B5CF6',
'#EC4899',
'#6B7280'
];
const getNextTagColor = () => {
const usedColors = data.tags.map((t) => t.color);
const availableColors = TAG_COLORS.filter((c) => !usedColors.includes(c));
return availableColors.length > 0 ? availableColors[0] : TAG_COLORS[0];
};
return {
// Event methods
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
// Data methods
getFolders: () => [...data.folders].sort((a, b) => a.order - b.order),
getTags: () => [...data.tags],
createFolder: (name: string, parentId?: string | null): Folder => {
const folder: Folder = {
id: generateId('folder'),
name,
parentId: parentId || null,
order: data.folders.length,
createdAt: new Date().toISOString()
};
data.folders.push(folder);
saveData();
return folder;
},
renameFolder: (id: string, name: string) => {
const folder = data.folders.find((f) => f.id === id);
if (folder) {
folder.name = name;
saveData();
}
},
deleteFolder: (id: string) => {
data.folders = data.folders.filter((f) => f.id !== id && f.parentId !== id);
Object.keys(data.projectMeta).forEach((projectPath) => {
if (data.projectMeta[projectPath].folderId === id) {
data.projectMeta[projectPath].folderId = null;
}
});
saveData();
},
getProjectCountInFolder: (folderId: string | null): number => {
return Object.values(data.projectMeta).filter((meta) => meta.folderId === folderId).length;
},
createTag: (name: string, color?: string): Tag => {
const tag: Tag = {
id: generateId('tag'),
name,
color: color || getNextTagColor(),
createdAt: new Date().toISOString()
};
data.tags.push(tag);
saveData();
return tag;
},
renameTag: (id: string, name: string) => {
const tag = data.tags.find((t) => t.id === id);
if (tag) {
tag.name = name;
saveData();
}
},
changeTagColor: (id: string, color: string) => {
const tag = data.tags.find((t) => t.id === id);
if (tag) {
tag.color = color;
saveData();
}
},
deleteTag: (id: string) => {
data.tags = data.tags.filter((t) => t.id !== id);
Object.keys(data.projectMeta).forEach((projectPath) => {
data.projectMeta[projectPath].tagIds = data.projectMeta[projectPath].tagIds.filter((tagId) => tagId !== id);
});
saveData();
},
moveProjectToFolder: (projectPath: string, folderId: string | null) => {
if (!data.projectMeta[projectPath]) {
data.projectMeta[projectPath] = { folderId: null, tagIds: [] };
}
data.projectMeta[projectPath].folderId = folderId;
saveData();
},
addTagToProject: (projectPath: string, tagId: string) => {
if (!data.projectMeta[projectPath]) {
data.projectMeta[projectPath] = { folderId: null, tagIds: [] };
}
const meta = data.projectMeta[projectPath];
if (!meta.tagIds.includes(tagId)) {
meta.tagIds.push(tagId);
saveData();
}
},
removeTagFromProject: (projectPath: string, tagId: string) => {
const meta = data.projectMeta[projectPath];
if (meta) {
meta.tagIds = meta.tagIds.filter((id) => id !== tagId);
saveData();
}
},
getProjectMeta: (projectPath: string): ProjectMeta | null => {
return data.projectMeta[projectPath] || null;
},
getProjectsInFolder: (folderId: string | null): string[] => {
return Object.keys(data.projectMeta).filter((projectPath) => data.projectMeta[projectPath].folderId === folderId);
},
getProjectsWithTag: (tagId: string): string[] => {
return Object.keys(data.projectMeta).filter((projectPath) =>
data.projectMeta[projectPath].tagIds.includes(tagId)
);
}
};
}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
@@ -9,6 +9,7 @@ import { Columns } from '@noodl-core-ui/components/layout/Columns';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { FolderTree } from '@noodl-core-ui/preview/launcher/Launcher/components/FolderTree';
import { LauncherPage } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherPage';
import {
CloudSyncType,
@@ -23,6 +24,7 @@ import { ProjectList } from '@noodl-core-ui/preview/launcher/Launcher/components
import { ProjectSettingsModal } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectSettingsModal';
import { ViewModeToggle } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
import { useProjectList } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectList';
import { useProjectOrganization } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import { MOCK_PROJECTS } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useLauncherContext, ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
@@ -33,6 +35,8 @@ export function Projects({}: ProjectsViewProps) {
viewMode,
setViewMode,
projects: allProjects,
selectedFolderId,
setSelectedFolderId,
onCreateProject,
onOpenProject,
onLaunchProject,
@@ -40,8 +44,38 @@ export function Projects({}: ProjectsViewProps) {
onDeleteProject
} = useLauncherContext();
const { getProjectMeta, getProjectsInFolder, folders, moveProjectToFolder } = useProjectOrganization();
const [selectedProjectId, setSelectedProjectId] = useState(null);
const uniqueTypes = [...new Set(allProjects.map((item) => item.cloudSyncMeta.type))];
const [movingProject, setMovingProject] = useState<LauncherProjectData | null>(null);
// Filter projects based on selected folder
const filteredByFolder = useMemo(() => {
if (selectedFolderId === null) {
// "All Projects" - show everything
return allProjects;
} else if (selectedFolderId === 'uncategorized') {
// "Uncategorized" - show projects without a folder
return allProjects.filter((project) => {
const meta = getProjectMeta(project.localPath);
return !meta || meta.folderId === null;
});
} else {
// Specific folder - show projects in that folder
const projectPathsInFolder = getProjectsInFolder(selectedFolderId);
return allProjects.filter((project) => projectPathsInFolder.includes(project.localPath));
}
}, [allProjects, selectedFolderId, getProjectMeta, getProjectsInFolder]);
// Calculate counts for folder tree
const uncategorizedCount = useMemo(() => {
return allProjects.filter((project) => {
const meta = getProjectMeta(project.localPath);
return !meta || meta.folderId === null;
}).length;
}, [allProjects, getProjectMeta]);
const uniqueTypes = [...new Set(filteredByFolder.map((item) => item.cloudSyncMeta.type))];
const visibleTypesDropdownItems: SelectOption[] = [
{ label: 'All projects', value: 'all' },
...uniqueTypes.map((type) => ({ label: `Only ${type.toLowerCase()} projects`, value: type }))
@@ -54,7 +88,7 @@ export function Projects({}: ProjectsViewProps) {
searchTerm,
setSearchTerm
} = useLauncherSearchBar({
allItems: allProjects,
allItems: filteredByFolder,
filterDropdownItems: visibleTypesDropdownItems,
propertyNameToFilter: 'cloudSyncMeta.type'
});
@@ -74,6 +108,21 @@ export function Projects({}: ProjectsViewProps) {
setSelectedProjectId(null);
}
function onMoveToFolder(project: LauncherProjectData) {
setMovingProject(project);
}
function onCloseFolderPicker() {
setMovingProject(null);
}
function handleMoveToFolder(folderId: string | null) {
if (movingProject) {
moveProjectToFolder(movingProject.localPath, folderId);
setMovingProject(null);
}
}
function onImportProjectClick() {
onOpenProject?.();
}
@@ -83,103 +132,256 @@ export function Projects({}: ProjectsViewProps) {
}
return (
<LauncherPage
title="Recent Projects"
headerSlot={
<HStack hasSpacing>
<PrimaryButton
label="Open project"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onImportProjectClick}
/>
<PrimaryButton label="Create new project" size={PrimaryButtonSize.Small} onClick={onNewProjectClick} />
</HStack>
}
>
<ProjectSettingsModal
isVisible={selectedProjectId !== null}
onClose={onCloseProjectSettings}
projectData={projects.find((project) => project.id === selectedProjectId)}
/>
<HStack hasSpacing={4} UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<LauncherSearchBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filterValue={filterValue}
setFilterValue={setFilterValue}
filterDropdownItems={visibleTypesDropdownItems}
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* Folder Tree Sidebar */}
<div style={{ width: '240px', borderRight: '1px solid var(--theme-color-border-default)', flexShrink: 0 }}>
<FolderTree
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
totalProjectCount={allProjects.length}
uncategorizedProjectCount={uncategorizedCount}
/>
<ViewModeToggle mode={viewMode} onChange={setViewMode} />
</HStack>
</div>
<Box hasTopSpacing={4}>
{viewMode === ViewMode.List ? (
<ProjectList
projects={sortedProjects}
sortField={sortField}
sortDirection={sortDirection}
onSort={setSorting}
onProjectClick={(project) => onLaunchProject?.(project.id)}
onOpenFolder={(project) => onOpenProjectFolder?.(project.id)}
onSettings={(project) => onOpenProjectSettings(project.id)}
onDelete={(project) => onDeleteProject?.(project.id)}
{/* Main Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
<LauncherPage
title="Recent Projects"
headerSlot={
<HStack hasSpacing>
<PrimaryButton
label="Open project"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onImportProjectClick}
/>
<PrimaryButton label="Create new project" size={PrimaryButtonSize.Small} onClick={onNewProjectClick} />
</HStack>
}
>
<ProjectSettingsModal
isVisible={selectedProjectId !== null}
onClose={onCloseProjectSettings}
projectData={projects.find((project) => project.id === selectedProjectId)}
/>
) : (
<>
{/* TODO: make project list legend and grid reusable */}
<Box hasBottomSpacing={4}>
<HStack hasSpacing>
<div style={{ width: 100 }} />
<div style={{ width: '100%' }}>
<Columns layoutString={'1 1 1'}>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Name
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Version control
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Contributors
</Label>
</Columns>
</div>
</HStack>
</Box>
<Columns layoutString="1" hasXGap hasYGap>
{projects.map((project) => (
<LauncherProjectCard
key={project.id}
{...project}
contextMenuItems={[
{
label: 'Launch project',
onClick: () => onLaunchProject?.(project.id)
},
{
label: 'Open project folder',
onClick: () => onOpenProjectFolder?.(project.id)
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
},
'divider',
{
label: 'Delete project',
onClick: () => onDeleteProject?.(project.id),
icon: IconName.Trash,
isDangerous: true
}
]}
/>
))}
</Columns>
</>
)}
</Box>
</LauncherPage>
<HStack hasSpacing={4} UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<LauncherSearchBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filterValue={filterValue}
setFilterValue={setFilterValue}
filterDropdownItems={visibleTypesDropdownItems}
/>
<ViewModeToggle mode={viewMode} onChange={setViewMode} />
</HStack>
<Box hasTopSpacing={4}>
{viewMode === ViewMode.List ? (
<ProjectList
projects={sortedProjects}
sortField={sortField}
sortDirection={sortDirection}
onSort={setSorting}
onProjectClick={(project) => onLaunchProject?.(project.id)}
onOpenFolder={(project) => onOpenProjectFolder?.(project.id)}
onSettings={(project) => onOpenProjectSettings(project.id)}
onDelete={(project) => onDeleteProject?.(project.id)}
/>
) : (
<>
{/* TODO: make project list legend and grid reusable */}
<Box hasBottomSpacing={4}>
<HStack hasSpacing>
<div style={{ width: 100 }} />
<div style={{ width: '100%' }}>
<Columns layoutString={'1 1 1'}>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Name
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Version control
</Label>
<Label variant={TextType.Shy} size={LabelSize.Small}>
Contributors
</Label>
</Columns>
</div>
</HStack>
</Box>
<Columns layoutString="1" hasXGap hasYGap>
{projects.map((project) => (
<LauncherProjectCard
key={project.id}
{...project}
onClick={() => onLaunchProject?.(project.id)}
contextMenuItems={[
{
label: 'Launch project',
onClick: () => onLaunchProject?.(project.id)
},
{
label: 'Open project folder',
onClick: () => onOpenProjectFolder?.(project.id)
},
{
label: 'Move to folder...',
onClick: () => onMoveToFolder(project)
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
},
'divider',
{
label: 'Delete project',
onClick: () => onDeleteProject?.(project.id),
icon: IconName.Trash,
isDangerous: true
}
]}
/>
))}
</Columns>
</>
)}
</Box>
{/* Folder Picker Modal */}
{movingProject && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)'
}}
onClick={onCloseFolderPicker}
/>
<div
style={{
position: 'relative',
backgroundColor: 'var(--theme-color-bg-2)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--spacing-6)',
minWidth: '400px',
maxWidth: '500px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
zIndex: 1001
}}
>
<h3
style={{
fontSize: '20px',
fontWeight: 600,
color: 'var(--theme-color-fg-default)',
margin: '0 0 var(--spacing-3) 0',
lineHeight: 1.3
}}
>
Move "{movingProject.title}" to folder
</h3>
<div
style={{
marginBottom: 'var(--spacing-6)',
maxHeight: '300px',
overflowY: 'auto'
}}
>
<button
onClick={() => handleMoveToFolder(null)}
style={{
width: '100%',
textAlign: 'left',
padding: 'var(--spacing-2) var(--spacing-3)',
marginBottom: 'var(--spacing-1)',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-3)';
}}
>
Uncategorized
</button>
{folders.map((folder) => (
<button
key={folder.id}
onClick={() => handleMoveToFolder(folder.id)}
style={{
width: '100%',
textAlign: 'left',
padding: 'var(--spacing-2) var(--spacing-3)',
marginBottom: 'var(--spacing-1)',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-3)';
}}
>
{folder.name}
</button>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={onCloseFolderPicker}
style={{
padding: 'var(--spacing-2) var(--spacing-4)',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-3)';
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
</LauncherPage>
</div>
</div>
);
}

View File

@@ -6,25 +6,67 @@
*/
import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { filesystem } from '@noodl/platform';
import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { LocalProjectsModel } from '../../utils/LocalProjectsModel';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
export interface ProjectsPageProps extends IRouteProps {
from: TSFixme;
}
/**
* Map LocalProjectsModel ProjectItem to LauncherProjectData format
*/
function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
return {
id: project.id,
title: project.name || 'Untitled',
localPath: project.retainedProjectDirectory,
lastOpened: new Date(project.latestAccessed).toISOString(),
imageSrc: project.thumbURI || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
cloudSyncMeta: {
type: CloudSyncType.None // TODO: Detect git repos in future
}
// Git-related fields will be populated in future tasks
};
}
export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// Fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initial load
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
const projects = LocalProjectsModel.instance.getProjects();
setRealProjects(projects.map(mapProjectToLauncherData));
};
loadProjects();
}, []);
// Subscribe to project list changes
useEventListener(LocalProjectsModel.instance, 'myProjectsChanged', () => {
console.log('🔔 Projects list changed, updating dashboard');
const projects = LocalProjectsModel.instance.getProjects();
setRealProjects(projects.map(mapProjectToLauncherData));
});
const handleCreateProject = useCallback(async () => {
try {
const direntry = await filesystem.openDialog({
@@ -196,6 +238,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
return (
<Launcher
projects={realProjects}
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onLaunchProject={handleLaunchProject}

View File

@@ -0,0 +1,340 @@
/**
* ProjectOrganizationService
*
* Manages project organization through folders and tags.
* Data is stored client-side in electron-store and keyed by project path.
*/
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
// ============================================================================
// Types
// ============================================================================
export interface Folder {
id: string;
name: string;
parentId: string | null; // null = root level
order: number;
createdAt: string;
}
export interface Tag {
id: string;
name: string;
color: string; // hex color
createdAt: string;
}
export interface ProjectMeta {
folderId: string | null;
tagIds: string[];
customName?: string;
notes?: string;
}
export interface ProjectOrganizationData {
version: 1;
folders: Folder[];
tags: Tag[];
projectMeta: Record<string, ProjectMeta>; // keyed by project path
}
// Tag color palette
export const TAG_COLORS = [
'#EF4444', // Red
'#F97316', // Orange
'#EAB308', // Yellow
'#22C55E', // Green
'#06B6D4', // Cyan
'#3B82F6', // Blue
'#8B5CF6', // Purple
'#EC4899', // Pink
'#6B7280' // Gray
];
// ============================================================================
// Service
// ============================================================================
export class ProjectOrganizationService extends EventDispatcher {
private static _instance: ProjectOrganizationService;
private data: ProjectOrganizationData;
private storageKey = 'projectOrganization';
private constructor() {
super();
this.data = this.loadData();
}
static get instance(): ProjectOrganizationService {
if (!ProjectOrganizationService._instance) {
ProjectOrganizationService._instance = new ProjectOrganizationService();
}
return ProjectOrganizationService._instance;
}
// ============================================================================
// Storage
// ============================================================================
private loadData(): ProjectOrganizationData {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('[ProjectOrganizationService] Failed to load data:', error);
}
// Return default empty structure
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
}
private saveData(): void {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
this.notifyListeners('dataChanged', this.data);
} catch (error) {
console.error('[ProjectOrganizationService] Failed to save data:', error);
}
}
// ============================================================================
// Folder Operations
// ============================================================================
createFolder(name: string, parentId?: string | null): Folder {
const folder: Folder = {
id: this.generateId('folder'),
name,
parentId: parentId || null,
order: this.data.folders.length,
createdAt: new Date().toISOString()
};
this.data.folders.push(folder);
this.saveData();
this.notifyListeners('folderCreated', folder);
return folder;
}
renameFolder(id: string, name: string): void {
const folder = this.data.folders.find((f) => f.id === id);
if (!folder) {
console.warn('[ProjectOrganizationService] Folder not found:', id);
return;
}
folder.name = name;
this.saveData();
this.notifyListeners('folderRenamed', { id, name });
}
deleteFolder(id: string): void {
// Remove folder
this.data.folders = this.data.folders.filter((f) => f.id !== id);
// Remove child folders
this.data.folders = this.data.folders.filter((f) => f.parentId !== id);
// Move projects in deleted folder to uncategorized
Object.keys(this.data.projectMeta).forEach((projectPath) => {
if (this.data.projectMeta[projectPath].folderId === id) {
this.data.projectMeta[projectPath].folderId = null;
}
});
this.saveData();
this.notifyListeners('folderDeleted', id);
}
reorderFolder(id: string, newOrder: number): void {
const folder = this.data.folders.find((f) => f.id === id);
if (!folder) return;
folder.order = newOrder;
this.saveData();
this.notifyListeners('folderReordered', { id, order: newOrder });
}
getFolders(): Folder[] {
return [...this.data.folders].sort((a, b) => a.order - b.order);
}
getFolder(id: string): Folder | undefined {
return this.data.folders.find((f) => f.id === id);
}
// ============================================================================
// Tag Operations
// ============================================================================
createTag(name: string, color?: string): Tag {
const tag: Tag = {
id: this.generateId('tag'),
name,
color: color || this.getNextTagColor(),
createdAt: new Date().toISOString()
};
this.data.tags.push(tag);
this.saveData();
this.notifyListeners('tagCreated', tag);
return tag;
}
renameTag(id: string, name: string): void {
const tag = this.data.tags.find((t) => t.id === id);
if (!tag) {
console.warn('[ProjectOrganizationService] Tag not found:', id);
return;
}
tag.name = name;
this.saveData();
this.notifyListeners('tagRenamed', { id, name });
}
changeTagColor(id: string, color: string): void {
const tag = this.data.tags.find((t) => t.id === id);
if (!tag) return;
tag.color = color;
this.saveData();
this.notifyListeners('tagColorChanged', { id, color });
}
deleteTag(id: string): void {
// Remove tag
this.data.tags = this.data.tags.filter((t) => t.id !== id);
// Remove tag from all projects
Object.keys(this.data.projectMeta).forEach((projectPath) => {
const meta = this.data.projectMeta[projectPath];
meta.tagIds = meta.tagIds.filter((tagId) => tagId !== id);
});
this.saveData();
this.notifyListeners('tagDeleted', id);
}
getTags(): Tag[] {
return [...this.data.tags];
}
getTag(id: string): Tag | undefined {
return this.data.tags.find((t) => t.id === id);
}
private getNextTagColor(): string {
const usedColors = this.data.tags.map((t) => t.color);
const availableColors = TAG_COLORS.filter((c) => !usedColors.includes(c));
return availableColors.length > 0 ? availableColors[0] : TAG_COLORS[0];
}
// ============================================================================
// Project Organization
// ============================================================================
moveProjectToFolder(projectPath: string, folderId: string | null): void {
if (!this.data.projectMeta[projectPath]) {
this.data.projectMeta[projectPath] = {
folderId: null,
tagIds: []
};
}
this.data.projectMeta[projectPath].folderId = folderId;
this.saveData();
this.notifyListeners('projectMoved', { projectPath, folderId });
}
addTagToProject(projectPath: string, tagId: string): void {
if (!this.data.projectMeta[projectPath]) {
this.data.projectMeta[projectPath] = {
folderId: null,
tagIds: []
};
}
const meta = this.data.projectMeta[projectPath];
if (!meta.tagIds.includes(tagId)) {
meta.tagIds.push(tagId);
this.saveData();
this.notifyListeners('projectTagAdded', { projectPath, tagId });
}
}
removeTagFromProject(projectPath: string, tagId: string): void {
const meta = this.data.projectMeta[projectPath];
if (!meta) return;
meta.tagIds = meta.tagIds.filter((id) => id !== tagId);
this.saveData();
this.notifyListeners('projectTagRemoved', { projectPath, tagId });
}
getProjectMeta(projectPath: string): ProjectMeta | null {
return this.data.projectMeta[projectPath] || null;
}
// ============================================================================
// Queries
// ============================================================================
getProjectsInFolder(folderId: string | null): string[] {
return Object.keys(this.data.projectMeta).filter((projectPath) => {
const meta = this.data.projectMeta[projectPath];
return meta.folderId === folderId;
});
}
getProjectsWithTag(tagId: string): string[] {
return Object.keys(this.data.projectMeta).filter((projectPath) => {
const meta = this.data.projectMeta[projectPath];
return meta.tagIds.includes(tagId);
});
}
getProjectCountInFolder(folderId: string | null): number {
return this.getProjectsInFolder(folderId).length;
}
// ============================================================================
// Utilities
// ============================================================================
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// For debugging
exportData(): ProjectOrganizationData {
return JSON.parse(JSON.stringify(this.data));
}
importData(data: ProjectOrganizationData): void {
this.data = data;
this.saveData();
}
clearAll(): void {
this.data = {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
this.saveData();
this.notifyListeners('dataCleared', null);
}
}

View File

@@ -219,13 +219,13 @@ TODO: review this icon
.components-panel-item-selected {
line-height: 36px;
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
opacity: 1;
}
.components-panel-item-selected .caret-icon-container {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
}
.components-panel-item-selected .components-panel-item-selected {
@@ -234,13 +234,13 @@ TODO: review this icon
.components-panel-item-selected:hover,
.components-panel-item-selected:hover .caret-icon-container {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-bg-5);
}
.components-panel-item-selected .components-panel-edit-button:hover,
.components-panel-item-selected .components-panel-item-dropdown:hover {
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-highlight);
}
.is-folder-component:hover .components-panel-folder-label {

View File

@@ -122,9 +122,10 @@
width: 0;
position: absolute;
pointer-events: none;
border-bottom-color: var(--theme-color-bg-5);
border-bottom-color: var(--theme-color-bg-4);
border-width: 10px;
margin-left: -10px;
transform: translateX(0);
}
.popup-layer-popout-arrow.right {

View File

@@ -1,111 +1,112 @@
.Root {
container-name: editortopbar;
height: 36px;
background-color: var(--theme-color-bg-2);
border-bottom: 2px solid var(--theme-color-bg-1);
display: flex;
align-items: stretch;
justify-content: space-between;
}
.LeftSide,
.RightSide {
display: flex;
align-items: stretch;
// please use these general selectors with great caution
> div {
display: flex;
align-items: center;
}
}
.LeftSide {
flex-grow: 1;
}
.is-padded-s {
padding: 0 4px;
}
.is-padded {
padding: 0 8px;
}
.is-padded-l {
padding: 0 16px;
}
.LeftSide {
> div:not(:last-child) {
border-right: 1px solid var(--theme-color-bg-1);
}
}
.RightSide {
> div:not(:first-child) {
border-left: 1px solid var(--theme-color-bg-1);
}
}
.UrlBarWrapper {
flex-grow: 1;
min-width: 150px;
max-width: 500px;
margin-right: -1px;
}
.UrlBarTextInput {
width: 100%;
svg {
cursor: pointer;
&:hover {
path {
fill: var(--theme-color-fg-highlight);
}
}
}
}
.TooltipPositioner {
height: 32px;
padding-bottom: 1px;
display: flex !important;
flex-direction: column;
justify-content: center;
}
.TopbarSelect {
display: flex;
align-items: center;
cursor: pointer;
* {
color: var(--theme-color-fg-default);
fill: var(--theme-color-fg-default);
}
&:hover * {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
}
}
.ZoomSelect {
padding-right: 4px;
padding-left: 8px;
}
.DesignPreviewModeButton {
cursor: pointer;
pointer-events: all;
}
.DeployButton {
.Root.is-small & {
padding: 0 4px 0 8px;
min-width: 0;
}
}
.Root {
container-name: editortopbar;
height: 36px;
background-color: var(--theme-color-bg-2);
border-bottom: 2px solid var(--theme-color-bg-1);
display: flex;
align-items: stretch;
justify-content: space-between;
}
.LeftSide,
.RightSide {
display: flex;
align-items: stretch;
// please use these general selectors with great caution
> div {
display: flex;
align-items: center;
}
}
.LeftSide {
flex-grow: 1;
}
.is-padded-s {
padding: 0 4px;
}
.is-padded {
padding: 0 8px;
}
.is-padded-l {
padding: 0 16px;
}
.LeftSide {
> div:not(:last-child) {
border-right: 1px solid var(--theme-color-bg-1);
}
}
.RightSide {
> div:not(:first-child) {
border-left: 1px solid var(--theme-color-bg-1);
}
}
.UrlBarWrapper {
flex-grow: 1;
min-width: 150px;
max-width: 500px;
margin-right: -1px;
}
.UrlBarTextInput {
width: 100%;
svg {
cursor: pointer;
&:hover {
path {
fill: var(--theme-color-fg-highlight);
}
}
}
}
.TooltipPositioner {
height: 32px;
padding-bottom: 1px;
display: flex !important;
flex-direction: column;
justify-content: center;
}
.TopbarSelect {
display: flex;
align-items: center;
cursor: pointer;
* {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
}
&:hover * {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
opacity: 0.8;
}
}
.ZoomSelect {
padding-right: 4px;
padding-left: 8px;
}
.DesignPreviewModeButton {
cursor: pointer;
pointer-events: all;
}
.DeployButton {
.Root.is-small & {
padding: 0 4px 0 8px;
min-width: 0;
}
}

View File

@@ -76,7 +76,7 @@
}
&.is-current {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
position: relative;
z-index: 1;
}

View File

@@ -1,12 +1,14 @@
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
import classNames from 'classnames';
import React, { ReactNode, useEffect, useState } from 'react';
import { NodeType } from '@noodl-constants/NodeType';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import css from './NodePickerCategory.module.scss';
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
interface NodePickerCategoryProps {
title: string;
@@ -86,7 +88,7 @@ export default function NodePickerCategory({
css['Arrow'],
isCollapsedState ? css['Arrow--is-collapsed'] : css['Arrow--is-not-collapsed']
])}
src="../assets/icons/editor/right_arrow_22.svg"
src="/assets/icons/editor/right_arrow_22.svg"
/>
</header>

View File

@@ -180,7 +180,7 @@ export function NodeLibrary({ model, parentModel, pos, attachToRoot, runtimeType
createNewComment(model, pos);
e.stopPropagation();
}}
icon={<img src="../assets/icons/comment.svg" />}
icon={<img src="/assets/icons/comment.svg" />}
/>
</NodePickerSection>
) : null}

View File

@@ -48,8 +48,8 @@
}
.lesson-item.selected {
background-color: var(--theme-color-fg-highlight);
color: var(--theme-color-secondary-dim);
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
opacity: 1;
transition: background-color 200ms, opacity 200ms;
}
@@ -128,9 +128,9 @@
}
.lesson-item-popup {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-3);
width: 512px;
color: white;
color: var(--theme-color-fg-highlight);
padding: 8px;
}

View File

@@ -17,10 +17,10 @@
}
&.is-active {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
&:hover {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-bg-5);
}
}
}
@@ -33,4 +33,4 @@
.SearchResults {
overflow: hidden overlay;
}
}