diff --git a/.changeset/nested-folders.md b/.changeset/nested-folders.md new file mode 100644 index 00000000..20b48467 --- /dev/null +++ b/.changeset/nested-folders.md @@ -0,0 +1,5 @@ +--- +"@open-slide/core": minor +--- + +Add multi-level folder nesting and an "All slides" view to the slide organizer. diff --git a/packages/core/skills/create-slide/SKILL.md b/packages/core/skills/create-slide/SKILL.md index 4ece2c44..504845a2 100644 --- a/packages/core/skills/create-slide/SKILL.md +++ b/packages/core/skills/create-slide/SKILL.md @@ -80,11 +80,67 @@ Read the **`slide-authoring`** skill before writing — it covers the file contr Run the checklist in `slide-authoring` ("Self-review before finishing"). It covers structural correctness, layout discipline, and asset existence. -## Step 8 — Hand off to the user +## Step 8 — Place the deck in a folder (optional) + +Decks are organized into **folders** via the manifest `slides/.folders.json`. This +is the **one file outside `slides//` you may edit** — and only to organize +decks. Still never touch `package.json`, `open-slide.config.ts`, themes, or other +slides. + +### How `slides/.folders.json` works + +```jsonc +{ + "folders": [ + { "id": "f-1a2b3c4d", "name": "Testing", "icon": { "type": "color", "value": "#2e6f8e" } }, + { "id": "f-5e6f7a8b", "name": "Spain", "icon": { "type": "color", "value": "#3a8a5f" }, "parentId": "f-1a2b3c4d" }, + { "id": "f-9c0d1e2f", "name": "Portugal", "icon": { "type": "color", "value": "#8e5a2e" }, "parentId": "f-5e6f7a8b" } + ], + "assignments": { + "spain-madrid": "f-9c0d1e2f" + } +} +``` + +- **`folders[]`** — each folder is `{ id, name, icon, parentId? }`. + - `id` is `f-` followed by 8 lowercase hex chars (e.g. `f-1a2b3c4d`). Must be unique. + - `icon` is `{ "type": "color", "value": "#rrggbb" }` or `{ "type": "emoji", "value": "📁" }`. + - **`parentId`** (optional) nests this folder **under** another folder by its `id`. + Omit it (or set `null`) for a top-level folder. **Nesting is multi-level**: + `Testing › Spain › Portugal` is `Portugal.parentId → Spain`, `Spain.parentId → Testing`. +- **`assignments`** — maps a **slide id → exactly one folder id**. A slide whose id + is absent here (or points to a non-existent folder) shows under **Draft** (unassigned). + A deck belongs to **one** folder only — never list it under several. + +### How decks are listed (important — assign to the LEAF folder) + +Selecting a folder in the home view shows its **own** decks first, then — grouped +by subfolder, depth-first, under a separator — the decks of **all its descendant +folders**. The folder's count (sidebar and header) is **recursive** (own + descendants). + +Consequence: **assign each deck to the most specific (leaf) folder it belongs to.** +It will still surface when the user browses any ancestor. Example: a Portugal-specific +deck goes in `Portugal`, and it appears when browsing `Portugal`, `Spain`, *and* +`Testing` — no need (and don't) duplicate the assignment upward. + +### What to do + +- **Default: leave the deck unassigned (Draft).** Don't edit `.folders.json` unless + the user asks for a folder. After hand-off, the user can drag it into a folder in + the dev UI. +- **If the user names a target folder:** find it in `folders` by `name`, then add + `"": ""` to `assignments`. If the target folder (or an + intermediate level of the path) doesn't exist yet, create it in `folders` with a + fresh unique `f-xxxxxxxx` id and set `parentId` to build the path. Confirm the + full path with the user before writing when you had to create folders. +- Preserve existing `folders` and `assignments` entries — only add/update. + +## Step 9 — Hand off to the user Tell the user: - The slide id and file path you created. +- Which folder it was assigned to (or that it's in Draft, unassigned). - That the dev server will hot-reload — they can open `http://localhost:5173/s/` (or refresh the home page). - If dev isn't running: `pnpm dev` from the repo root. diff --git a/packages/core/src/app/components/sidebar/folder-item.tsx b/packages/core/src/app/components/sidebar/folder-item.tsx index 87cee996..5f435426 100644 --- a/packages/core/src/app/components/sidebar/folder-item.tsx +++ b/packages/core/src/app/components/sidebar/folder-item.tsx @@ -1,14 +1,18 @@ -import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { ChevronRight, FolderInput, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { Folder, FolderIcon } from '@/lib/sdk'; -import { useLocale } from '@/lib/use-locale'; +import { format, useLocale } from '@/lib/use-locale'; import { cn } from '@/lib/utils'; import { IconPicker } from './icon-picker'; @@ -64,10 +68,15 @@ type Row = onRename: (name: string) => void; onChangeIcon: (icon: FolderIcon) => void; onDelete: () => void; + onMove: (parentId: string | null) => void; + allFolders: Folder[]; } | { kind: 'draft'; } + | { + kind: 'all'; + } | { kind: 'themes'; } @@ -75,16 +84,113 @@ type Row = kind: 'assets'; }; +function isDescendant(candidateId: string, rootId: string, all: Folder[]): boolean { + let current: string | null | undefined = candidateId; + const seen = new Set(); + while (current) { + if (seen.has(current)) return false; + seen.add(current); + if (current === rootId) return true; + const parent = all.find((f) => f.id === current)?.parentId; + current = parent ?? null; + } + return false; +} + +function buildChildrenByParent(all: Folder[]): Map { + const m = new Map(); + for (const f of all) { + const p = f.parentId ?? null; + const list = m.get(p) ?? []; + list.push(f); + m.set(p, list); + } + return m; +} + +/** + * Recursively renders the "Move under" targets as nested submenus: a folder with + * children becomes a flyout containing a "Move into " item plus its own + * children. The moved folder and its subtree are skipped (they can't be targets + * without creating a cycle). + */ +function MoveTargetItems({ + parentId, + all, + childrenByParent, + movedId, + currentParentId, + onMove, +}: { + parentId: string | null; + all: Folder[]; + childrenByParent: Map; + movedId: string; + currentParentId: string | null; + onMove: (parentId: string | null) => void; +}) { + const t = useLocale(); + const kids = (childrenByParent.get(parentId) ?? []).filter((f) => f.id !== movedId); + return ( + <> + {kids.map((f) => { + if (isDescendant(f.id, movedId, all)) return null; + const current = currentParentId === f.id; + const grandKids = (childrenByParent.get(f.id) ?? []).filter((c) => c.id !== movedId); + if (grandKids.length === 0) { + return ( + onMove(f.id)}> + + {f.name} + + ); + } + return ( + + + + {f.name} + + + onMove(f.id)}> + + {format(t.home.moveInto, { name: f.name })} + + + + + + ); + })} + + ); +} + export function FolderItem({ row, count, selected, + depth = 0, + hasChildren = false, + expanded = false, + onToggleExpand, onSelect, onDropSlide, }: { row: Row; count: number; selected: boolean; + depth?: number; + hasChildren?: boolean; + expanded?: boolean; + onToggleExpand?: () => void; onSelect: () => void; onDropSlide: (slideId: string) => void; }) { @@ -95,7 +201,7 @@ export function FolderItem({ const slideDragActive = useSlideDragActive(); const t = useLocale(); - const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets'; + const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets' && row.kind !== 'all'; const isSlideDrag = (e: React.DragEvent) => acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME); const handleDragEnter = (e: React.DragEvent) => { @@ -126,19 +232,23 @@ export function FolderItem({ const icon: FolderIcon = row.kind === 'draft' ? { type: 'emoji', value: '📝' } - : row.kind === 'themes' - ? { type: 'emoji', value: '🎨' } - : row.kind === 'assets' - ? { type: 'emoji', value: '🗂️' } - : row.folder.icon; + : row.kind === 'all' + ? { type: 'emoji', value: '📚' } + : row.kind === 'themes' + ? { type: 'emoji', value: '🎨' } + : row.kind === 'assets' + ? { type: 'emoji', value: '🗂️' } + : row.folder.icon; const label = row.kind === 'draft' ? t.home.draft - : row.kind === 'themes' - ? t.home.themes - : row.kind === 'assets' - ? t.home.assets - : row.folder.name; + : row.kind === 'all' + ? t.home.allSlides + : row.kind === 'themes' + ? t.home.themes + : row.kind === 'assets' + ? t.home.assets + : row.folder.name; const commitRename = () => { if (row.kind !== 'folder') return; @@ -147,6 +257,8 @@ export function FolderItem({ setRenaming(false); }; + const isFolder = row.kind === 'folder'; + return ( // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target wraps interactive children
0 ? { paddingLeft: `${8 + depth * 12}px` } : undefined} onDragEnter={handleDragEnter} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > + {isFolder && + (hasChildren ? ( + + ) : ( + + ))} + {row.kind === 'folder' && import.meta.env.DEV ? ( @@ -245,6 +376,29 @@ export function FolderItem({ {t.common.rename} + + + + {t.home.moveUnder} + + + row.onMove(null)} + > + + {t.home.moveTopLevel} + + + + row.onDelete()}> {t.common.delete} diff --git a/packages/core/src/app/components/sidebar/sidebar.tsx b/packages/core/src/app/components/sidebar/sidebar.tsx index ad09bbf4..47373e91 100644 --- a/packages/core/src/app/components/sidebar/sidebar.tsx +++ b/packages/core/src/app/components/sidebar/sidebar.tsx @@ -1,5 +1,5 @@ import { Plus } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { LanguageToggle } from '@/components/language-toggle'; import { ThemeToggle } from '@/components/theme-toggle'; @@ -12,14 +12,36 @@ import { IconPicker, PRESET_COLORS } from './icon-picker'; import { SidebarFooter } from './sidebar-footer'; export const DRAFT_ID = 'draft'; +export const ALL_ID = '__all__'; export const THEMES_ID = '__themes__'; export const ASSETS_ID = '__assets__'; export const FOLDER_DND_MIME = 'application/x-folder-id'; +const EXPANDED_STORAGE_KEY = 'open-slide:folders-expanded'; + +function readExpanded(): Set { + if (typeof window === 'undefined') return new Set(); + try { + const raw = window.localStorage.getItem(EXPANDED_STORAGE_KEY); + if (!raw) return new Set(); + const arr = JSON.parse(raw); + if (Array.isArray(arr)) return new Set(arr.filter((v) => typeof v === 'string')); + } catch {} + return new Set(); +} + +function writeExpanded(set: Set): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(set))); + } catch {} +} + export function Sidebar({ folders, countFor, + allCount, themesCount, assetsCount, selectedId, @@ -28,12 +50,14 @@ export function Sidebar({ onRename, onChangeIcon, onDelete, + onMove, onDropToFolder, onDropToDraft, onReorder, }: { folders: Folder[]; countFor: (folderId: string | null) => number; + allCount: number; themesCount: number; assetsCount: number; selectedId: string; @@ -42,12 +66,23 @@ export function Sidebar({ onRename: (id: string, name: string) => void; onChangeIcon: (id: string, icon: FolderIcon) => void; onDelete: (id: string) => void; + onMove: (id: string, parentId: string | null) => void; onDropToFolder: (folderId: string, slideId: string) => void; onDropToDraft: (slideId: string) => void; onReorder: (ids: string[]) => void; }) { const [dragId, setDragId] = useState(null); const [dropTarget, setDropTarget] = useState<{ id: string; before: boolean } | null>(null); + const [expanded, setExpanded] = useState>(() => readExpanded()); + const toggleExpand = (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + writeExpanded(next); + return next; + }); + }; const finishReorder = (toId: string, before: boolean) => { const fromId = dragId; @@ -122,6 +157,98 @@ export function Sidebar({ return () => document.removeEventListener('mousedown', onDown); }, [creating]); + const childrenByParent = new Map(); + for (const f of folders) { + const parent = f.parentId ?? null; + const list = childrenByParent.get(parent) ?? []; + list.push(f); + childrenByParent.set(parent, list); + } + + const renderTree = (parentId: string | null, depth: number): ReactNode[] => { + const kids = childrenByParent.get(parentId) ?? []; + const out: ReactNode[] = []; + for (const folder of kids) { + const hasChildren = (childrenByParent.get(folder.id) ?? []).length > 0; + const isExpanded = expanded.has(folder.id); + const isDropTarget = dropTarget?.id === folder.id; + const before = isDropTarget && dropTarget.before; + const after = isDropTarget && !dropTarget.before; + out.push( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop handle wraps the row +
{ + if (!import.meta.env.DEV) return; + e.dataTransfer.setData(FOLDER_DND_MIME, folder.id); + e.dataTransfer.effectAllowed = 'move'; + setDragId(folder.id); + }} + onDragEnd={() => { + setDragId(null); + setDropTarget(null); + }} + onDragOver={(e) => { + if (!e.dataTransfer.types.includes(FOLDER_DND_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const isBefore = e.clientY < rect.top + rect.height / 2; + if (!dropTarget || dropTarget.id !== folder.id || dropTarget.before !== isBefore) { + setDropTarget({ id: folder.id, before: isBefore }); + } + }} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + if (dropTarget?.id === folder.id) setDropTarget(null); + }} + onDrop={(e) => { + const fromId = e.dataTransfer.getData(FOLDER_DND_MIME); + if (!fromId) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const isBefore = e.clientY < rect.top + rect.height / 2; + finishReorder(folder.id, isBefore); + }} + > + onRename(folder.id, name), + onChangeIcon: (icon) => onChangeIcon(folder.id, icon), + onDelete: () => onDelete(folder.id), + onMove: (parent) => onMove(folder.id, parent), + allFolders: folders, + }} + count={countFor(folder.id)} + selected={selectedId === folder.id} + depth={depth} + hasChildren={hasChildren} + expanded={isExpanded} + onToggleExpand={() => toggleExpand(folder.id)} + onSelect={() => onSelect(folder.id)} + onDropSlide={(slideId) => onDropToFolder(folder.id, slideId)} + /> +
, + ); + if (hasChildren && isExpanded) { + out.push(...renderTree(folder.id, depth + 1)); + } + } + return out; + }; + return (