Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nested-folders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-slide/core": minor
---

Add multi-level folder nesting and an "All slides" view to the slide organizer.
58 changes: 57 additions & 1 deletion packages/core/skills/create-slide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/` 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
`"<slide-id>": "<folder-id>"` 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/<id>` (or refresh the home page).
- If dev isn't running: `pnpm dev` from the repo root.

Expand Down
180 changes: 167 additions & 13 deletions packages/core/src/app/components/sidebar/folder-item.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -64,27 +68,129 @@ 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';
}
| {
kind: 'assets';
};

function isDescendant(candidateId: string, rootId: string, all: Folder[]): boolean {
let current: string | null | undefined = candidateId;
const seen = new Set<string>();
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<string | null, Folder[]> {
const m = new Map<string | null, Folder[]>();
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 <name>" 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<string | null, Folder[]>;
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 (
<DropdownMenuItem key={f.id} disabled={current} onSelect={() => onMove(f.id)}>
<FolderIconChip icon={f.icon} />
<span className="truncate">{f.name}</span>
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub key={f.id}>
<DropdownMenuSubTrigger>
<FolderIconChip icon={f.icon} />
<span className="truncate">{f.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[300px] min-w-[180px] overflow-y-auto">
<DropdownMenuItem disabled={current} onSelect={() => onMove(f.id)}>
<FolderInput />
<span className="truncate">{format(t.home.moveInto, { name: f.name })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<MoveTargetItems
parentId={f.id}
all={all}
childrenByParent={childrenByParent}
movedId={movedId}
currentParentId={currentParentId}
onMove={onMove}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
</>
);
}

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;
}) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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
<div
Expand All @@ -159,11 +271,30 @@ export function FolderItem({
dragOver &&
'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
)}
style={isFolder && depth > 0 ? { paddingLeft: `${8 + depth * 12}px` } : undefined}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isFolder &&
(hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpand?.();
}}
aria-label={expanded ? t.home.folderCollapse : t.home.folderExpand}
className="-ml-1 flex size-4 shrink-0 items-center justify-center rounded text-foreground/50 transition-transform hover:bg-foreground/10 hover:text-foreground"
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
<ChevronRight className="size-3" />
</button>
) : (
<span className="-ml-1 inline-block size-4 shrink-0" aria-hidden />
))}

{row.kind === 'folder' && import.meta.env.DEV ? (
<Popover>
<PopoverTrigger asChild>
Expand Down Expand Up @@ -245,6 +376,29 @@ export function FolderItem({
<Pencil />
{t.common.rename}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
{t.home.moveUnder}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[300px] min-w-[180px] overflow-y-auto">
<DropdownMenuItem
disabled={(row.folder.parentId ?? null) === null}
onSelect={() => row.onMove(null)}
>
<span className="inline-block size-3 shrink-0" aria-hidden />
<span className="text-muted-foreground">{t.home.moveTopLevel}</span>
</DropdownMenuItem>
<MoveTargetItems
parentId={null}
all={row.allFolders}
childrenByParent={buildChildrenByParent(row.allFolders)}
movedId={row.folder.id}
currentParentId={row.folder.parentId ?? null}
onMove={row.onMove}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem variant="destructive" onSelect={() => row.onDelete()}>
<Trash2 />
{t.common.delete}
Expand Down
Loading