Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d5eea00
fix(backend): honor VOICEBOX_DATA_DIR environment variable
Lvigentini Aug 6, 2026
c065bf5
feat(backend): add folders for voices and clips, and profile duplication
Lvigentini Aug 6, 2026
6c1c4ee
feat(ui): list view, folders, and duplicate in the Generate tab
Lvigentini Aug 6, 2026
0bcd5cc
feat(stories): stereo project-rate mixdown, tracks, fades, speed, for…
Lvigentini Aug 6, 2026
c1ae72e
feat(ui): per-lane mixer, clip fades and speed, export format picker
Lvigentini Aug 6, 2026
7cb3792
fix(stories): delete a story's track settings with the story
Lvigentini Aug 6, 2026
1210a77
docs: document folders, duplication and story mixdown
Lvigentini Aug 7, 2026
c65a2f4
fix(stories): clicking a clip selects it instead of moving it
Lvigentini Aug 7, 2026
eed45b7
style(folders): make folder headers read as containers
Lvigentini Aug 7, 2026
5a1bd5c
fix(stories): restore clip dragging
Lvigentini Aug 7, 2026
39041ad
feat: story folders, drag-and-drop filing, ripple move, exact clip ti…
Lvigentini Aug 7, 2026
2d5e2e0
fix(ui): folder filter showed nothing, and drag needed a handle
Lvigentini Aug 7, 2026
30cb835
fix(tauri): let HTML5 drag and drop work inside the window
Lvigentini Aug 7, 2026
2b1b979
fix(stories): align the timeline ruler and fix story-folder invalidation
Lvigentini Aug 8, 2026
ab68aa3
feat(clips): scroll the folder list and add search within a folder
Lvigentini Aug 9, 2026
65141bd
feat(clips): stop the list emptying while a new search or folder loads
Lvigentini Aug 9, 2026
9a1a328
fix(stories): mixer slider, ducking determinism, and silent failures
Lvigentini Aug 9, 2026
4530217
fix(clips): reset paging on folder moves, localize the drag handle
Lvigentini Aug 9, 2026
b0fb144
feat(api): serve generations in any export container
Lvigentini Aug 9, 2026
3addcb1
feat(voices): make the profile list search-aware so #1016 composes
Lvigentini Aug 9, 2026
2c7050e
fix(folders): validation and cache gaps found in review
Lvigentini Aug 9, 2026
3460a59
fix(stories): bound the duck target and preserve the exception chain
Lvigentini Aug 9, 2026
b0b865c
fix(profiles): settle duplicate names by the insert, and harden the d…
Lvigentini Aug 9, 2026
7bc2ca7
fix(stories): WSOLA for per-clip speed, and plan splices once for stereo
Lvigentini Aug 10, 2026
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
390 changes: 390 additions & 0 deletions app/src/components/History/ClipFolderTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
import {
ChevronDown,
ChevronRight,
FolderPlus,
Inbox,
Layers,
MoreHorizontal,
Pencil,
Trash2,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import type { FolderKind, FolderResponse } from '@/lib/api/types';
import {
useCreateFolder,
useDeleteFolder,
useDetachFolder,
useFolders,
useUpdateFolder,
} from '@/lib/hooks/useFolders';
import { cn } from '@/lib/utils/cn';
import { isFolderDrag, readFolderDragData } from '@/lib/utils/folderDrag';
import { useUIStore } from '@/stores/uiStore';

/**
* What the clip list is currently filtered to.
*
* `uncategorised` is its own selection rather than a null folderId, because
* "no filter" and "clips in no folder" are different requests — the server
* distinguishes them too.
*/
export type ClipFolderSelection =
| { kind: 'all' }
| { kind: 'uncategorised' }
| { kind: 'folder'; folderId: string };

interface ClipFolderTreeProps {
selection: ClipFolderSelection;
onSelect: (selection: ClipFolderSelection) => void;
/** Which folder kind to manage. Clips and stories share this tree because
* both nest and both need the same create/rename/move/delete affordances. */
kind?: FolderKind;
/** Heading above the tree. */
title?: string;
/** Label for the "no filter" row. */
allLabel?: string;
/** Called when an item is dropped on a folder. folderId is null for the
* Uncategorised row. */
onDropItem?: (itemId: string, folderId: string | null) => void;
}

/** Sentinel for highlighting the Uncategorised row, which has no folder id. */
const UNCATEGORISED_DROP_ID = '__uncategorised__';

/** A folder plus its children, built once per folder list change. */
interface TreeNode {
folder: FolderResponse;
children: TreeNode[];
}

function buildTree(folders: FolderResponse[]): TreeNode[] {
const nodes = new Map<string, TreeNode>();
for (const folder of folders) nodes.set(folder.id, { folder, children: [] });

const roots: TreeNode[] = [];
for (const node of nodes.values()) {
const parentId = node.folder.parent_id;
// A parent that isn't in the list (deleted concurrently) would otherwise
// make the node unreachable — surface it at the root instead.
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}

export function ClipFolderTree({
selection,
onSelect,
kind = 'generation',
title,
allLabel,
onDropItem,
}: ClipFolderTreeProps) {
const { t } = useTranslation();
const { data: folders } = useFolders(kind);

const collapsedIds = useUIStore((state) => state.collapsedFolderIds[kind] ?? []);
const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed);

const createFolder = useCreateFolder(kind);
const updateFolder = useUpdateFolder(kind);
const detachFolder = useDetachFolder(kind);
const deleteFolder = useDeleteFolder(kind);

const [dialog, setDialog] = useState<
| { mode: 'create'; parentId: string | null }
| { mode: 'rename'; folder: FolderResponse }
| { mode: 'delete'; folder: FolderResponse }
| null
>(null);
const [draftName, setDraftName] = useState('');
const [dragOverId, setDragOverId] = useState<string | null>(null);

const tree = useMemo(() => buildTree(folders ?? []), [folders]);

const openCreate = (parentId: string | null) => {
setDraftName('');
setDialog({ mode: 'create', parentId });
};

const submitDialog = () => {
const trimmed = draftName.trim();
if (!dialog) return;

if (dialog.mode === 'create' && trimmed) {
createFolder.mutate({ name: trimmed, parentId: dialog.parentId });
} else if (dialog.mode === 'rename' && trimmed && trimmed !== dialog.folder.name) {
updateFolder.mutate({ folderId: dialog.folder.id, data: { name: trimmed } });
}
setDialog(null);
};

const renderNode = (node: TreeNode, depth: number) => {
const { folder, children } = node;
const collapsed = collapsedIds.includes(folder.id);
const isSelected = selection.kind === 'folder' && selection.folderId === folder.id;
const Chevron = collapsed ? ChevronRight : ChevronDown;

return (
<div key={folder.id}>
{/* Shaded and bold, matching the voice folders, so a folder never
reads as just another row in the list. */}
<div
onDragEnter={(e) => {
// Both enter and over must preventDefault; some engines only treat
// an element as a drop target once enter has been cancelled.
if (!onDropItem || !isFolderDrag(e)) return;
e.preventDefault();
setDragOverId(folder.id);
}}
onDragOver={(e) => {
if (!onDropItem || !isFolderDrag(e)) return;
// preventDefault is what marks this a valid drop target.
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(folder.id);
}}
onDragLeave={() => setDragOverId((id) => (id === folder.id ? null : id))}
onDrop={(e) => {
setDragOverId(null);
const payload = readFolderDragData(e);
if (!payload || payload.kind !== kind) return;
e.preventDefault();
onDropItem?.(payload.id, folder.id);
}}
className={cn(
'group/node my-0.5 flex items-center gap-1 rounded border border-border/60 bg-muted/60 pr-1 transition-colors',
isSelected && 'border-accent/60 bg-accent/40',
dragOverId === folder.id && 'border-accent bg-accent/50 ring-1 ring-accent',
)}
style={{ marginLeft: `${depth * 12}px` }}
>
{children.length > 0 ? (
<button
type="button"
onClick={() => toggleCollapsed(kind, folder.id)}
className="shrink-0 rounded p-0.5 hover:bg-accent/50"
aria-label={folder.name}
aria-expanded={!collapsed}
>
<Chevron className="h-3 w-3 text-muted-foreground" />
</button>
) : (
// Keeps leaf labels aligned with their expandable siblings.
<span className="w-4 shrink-0" />
)}
Comment on lines +181 to +194

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The collapse toggle needs an action label.

aria-label={folder.name} gives the toggle the same accessible name as the folder-selection button beside it. A screen reader user hears two controls named after the folder and cannot tell which one expands the subtree.

Use a dedicated label that names the action and the folder.

♿ Proposed fix
             <button
               type="button"
               onClick={() => toggleCollapsed(kind, folder.id)}
               className="shrink-0 rounded p-0.5 hover:bg-accent/50"
-              aria-label={folder.name}
+              aria-label={t(collapsed ? 'folders.expand' : 'folders.collapse', {
+                name: folder.name,
+              })}
               aria-expanded={!collapsed}
             >

Add folders.expand and folders.collapse to app/src/i18n/locales/en/translation.json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/History/ClipFolderTree.tsx` around lines 181 - 194, Update
the collapse toggle in the folder tree to use a dedicated translated action
label that distinguishes expanding from collapsing and includes the folder name,
instead of aria-label={folder.name}. Add the folders.expand and folders.collapse
translation keys to the English translations and select the appropriate key
based on collapsed state.


<button
type="button"
onClick={() => onSelect({ kind: 'folder', folderId: folder.id })}
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left"
>
<span className="truncate text-xs font-bold text-foreground">{folder.name}</span>
<span className="shrink-0 rounded bg-background/70 px-1 text-[10px] font-semibold tabular-nums text-muted-foreground">
{folder.item_count}
</span>
</button>

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/node:opacity-100 data-[state=open]:opacity-100"
aria-label={t('folders.actions', { name: folder.name })}
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openCreate(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
{t('folders.clip.newSubfolder')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDraftName(folder.name);
setDialog({ mode: 'rename', folder });
}}
>
<Pencil className="mr-2 h-4 w-4" />
{t('folders.rename')}
</DropdownMenuItem>
{folder.parent_id && (
<DropdownMenuItem onClick={() => detachFolder.mutate(folder.id)}>
<Layers className="mr-2 h-4 w-4" />
{t('folders.clip.moveToRoot')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDialog({ mode: 'delete', folder })}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('folders.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

{!collapsed && children.map((child) => renderNode(child, depth + 1))}
</div>
);
};

return (
<div className="flex flex-col gap-0.5 border-b pb-2 mb-2">
<div className="flex items-center justify-between px-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{title ?? t('folders.clip.filterTitle')}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => openCreate(null)}
aria-label={t('folders.new')}
>
<FolderPlus className="h-3.5 w-3.5" />
</Button>
</div>

<button
type="button"
onClick={() => onSelect({ kind: 'all' })}
className={cn(
'flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs',
selection.kind === 'all' ? 'bg-accent/40' : 'hover:bg-accent/20',
)}
>
<Layers className="h-3 w-3 shrink-0 text-muted-foreground" />
{allLabel ?? t('folders.clip.allClips')}
</button>

{/* Folders scroll on their own. With a few dozen folders the list
otherwise pushes the clips off-screen entirely, and "All clips" and
"Uncategorised" stay pinned outside it so both are always reachable. */}
<div className="max-h-[40vh] min-h-0 overflow-y-auto overscroll-contain">
{tree.map((node) => renderNode(node, 0))}
</div>

<button
type="button"
onClick={() => onSelect({ kind: 'uncategorised' })}
onDragEnter={(e) => {
if (!onDropItem || !isFolderDrag(e)) return;
e.preventDefault();
setDragOverId(UNCATEGORISED_DROP_ID);
}}
onDragOver={(e) => {
if (!onDropItem || !isFolderDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(UNCATEGORISED_DROP_ID);
}}
onDragLeave={() => setDragOverId((id) => (id === UNCATEGORISED_DROP_ID ? null : id))}
onDrop={(e) => {
setDragOverId(null);
const payload = readFolderDragData(e);
if (!payload || payload.kind !== kind) return;
e.preventDefault();
onDropItem?.(payload.id, null);
}}
className={cn(
'flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs transition-colors',
selection.kind === 'uncategorised' ? 'bg-accent/40' : 'hover:bg-accent/20',
dragOverId === UNCATEGORISED_DROP_ID && 'bg-accent/50 ring-1 ring-accent',
)}
>
<Inbox className="h-3 w-3 shrink-0 text-muted-foreground" />
{t('folders.uncategorised')}
</button>

<Dialog
open={dialog?.mode === 'create' || dialog?.mode === 'rename'}
onOpenChange={() => setDialog(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialog?.mode === 'rename'
? t('folders.renameDialog.title')
: t('folders.newDialog.title')}
</DialogTitle>
</DialogHeader>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={t('folders.newDialog.placeholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') submitDialog();
}}
aria-label={t('folders.newDialog.title')}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setDialog(null)}>
{t('common.cancel')}
</Button>
<Button onClick={submitDialog} disabled={!draftName.trim()}>
{dialog?.mode === 'rename' ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={dialog?.mode === 'delete'} onOpenChange={() => setDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('folders.deleteDialog.title')}</DialogTitle>
<DialogDescription>
{dialog?.mode === 'delete' &&
t('folders.deleteDialog.body', { name: dialog.folder.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDialog(null)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
if (dialog?.mode === 'delete') {
const deletedId = dialog.folder.id;
deleteFolder.mutate(deletedId);
// The filter would otherwise point at a folder that no
// longer exists, showing a permanently empty list.
if (selection.kind === 'folder' && selection.folderId === deletedId) {
onSelect({ kind: 'all' });
}
}
setDialog(null);
}}
>
{t('folders.deleteDialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
Loading