mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Fixed visual issues with new dashboard and added folder attribution
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FolderTree } from './FolderTree';
|
||||
export type { FolderTreeProps } from './FolderTree';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FolderTreeItem } from './FolderTreeItem';
|
||||
export type { FolderTreeItemProps } from './FolderTreeItem';
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TagPill, TagPillSize } from './TagPill';
|
||||
export type { TagPillProps } from './TagPill';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TagSelector } from './TagSelector';
|
||||
export type { TagSelectorProps } from './TagSelector';
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
background-color: var(--theme-color-secondary);
|
||||
background-color: var(--theme-color-bg-4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user