@drawnagency/primitives 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
- package/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
- package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
- package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
- package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
- package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
- package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
- package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
- package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +95 -47
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +11 -11
- package/dist/components/sections/register.js +11 -11
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTile.d.ts +15 -0
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTree.d.ts +20 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
- package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
- package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
- package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
- package/dist/hooks/useMediaPipeline.d.ts +14 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -25
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/media-dnd.d.ts +26 -0
- package/dist/lib/media-dnd.d.ts.map +1 -0
- package/dist/lib/media-folders.d.ts +59 -0
- package/dist/lib/media-folders.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/folder-drop.d.ts +22 -0
- package/dist/media/folder-drop.d.ts.map +1 -0
- package/dist/media/index.js +6 -2
- package/dist/media/types.d.ts +1 -1
- package/dist/media/types.d.ts.map +1 -1
- package/dist/media/utils.d.ts +7 -0
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -3
- package/dist/schemas/media.d.ts +11 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +581 -22
- package/src/components/shell/MediaBreadcrumb.tsx +77 -0
- package/src/components/shell/MediaFolderTile.tsx +141 -0
- package/src/components/shell/MediaFolderTree.tsx +127 -0
- package/src/components/shell/MediaLibraryModal.tsx +611 -229
- package/src/components/shell/MoveToFolderDialog.tsx +78 -0
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +216 -71
- package/src/hooks/useMediaFolderDnd.ts +129 -0
- package/src/hooks/useMediaPipeline.ts +162 -6
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/media-dnd.ts +62 -0
- package/src/lib/media-folders.ts +232 -0
- package/src/lib/registry.ts +8 -0
- package/src/media/folder-drop.ts +62 -0
- package/src/media/types.ts +2 -0
- package/src/media/utils.ts +10 -0
- package/src/schemas/media.ts +13 -0
- package/src/types/database.ts +55 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Fragment, useRef } from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import type { MediaFolder, MediaItem } from "../../media/types";
|
|
5
|
+
import { folderPathIds } from "../../lib/media-folders";
|
|
6
|
+
import { useFolderDropTarget } from "../../hooks/useMediaFolderDnd";
|
|
7
|
+
import type { MediaFolderDndBindings } from "./MediaFolderTree";
|
|
8
|
+
|
|
9
|
+
export interface MediaBreadcrumbProps {
|
|
10
|
+
folders: Record<string, MediaFolder>;
|
|
11
|
+
currentFolderId: string | null;
|
|
12
|
+
onNavigate: (id: string | null) => void;
|
|
13
|
+
dnd?: MediaFolderDndBindings;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function Segment({
|
|
18
|
+
label, target, onNavigate, dnd, isCurrent,
|
|
19
|
+
}: {
|
|
20
|
+
label: string;
|
|
21
|
+
target: string | null;
|
|
22
|
+
onNavigate: (id: string | null) => void;
|
|
23
|
+
dnd?: MediaFolderDndBindings;
|
|
24
|
+
isCurrent: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
const emptyFoldersRef = useRef<Record<string, MediaFolder>>({});
|
|
27
|
+
const emptyItemsRef = useRef<Record<string, MediaItem>>({});
|
|
28
|
+
const { ref, isDropTarget } = useFolderDropTarget<HTMLButtonElement>({
|
|
29
|
+
targetFolderId: target,
|
|
30
|
+
enabled: dnd !== undefined && !isCurrent,
|
|
31
|
+
foldersRef: dnd?.foldersRef ?? emptyFoldersRef,
|
|
32
|
+
itemsByIdRef: dnd?.itemsByIdRef ?? emptyItemsRef,
|
|
33
|
+
onMoveMedia: dnd?.onMoveMedia ?? (() => {}),
|
|
34
|
+
onMoveFolder: dnd?.onMoveFolder ?? (() => {}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
ref={ref}
|
|
40
|
+
type="button"
|
|
41
|
+
disabled={isCurrent}
|
|
42
|
+
onClick={() => onNavigate(target)}
|
|
43
|
+
className={cn(
|
|
44
|
+
"max-w-40 truncate rounded px-1 py-0.5 text-sm",
|
|
45
|
+
isCurrent
|
|
46
|
+
? "font-medium text-base-contrast"
|
|
47
|
+
: "cursor-pointer text-base-contrast-light hover:text-base-contrast",
|
|
48
|
+
isDropTarget && "ring-2 ring-primary ring-inset",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{label}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function MediaBreadcrumb({ folders, currentFolderId, onNavigate, dnd }: MediaBreadcrumbProps) {
|
|
57
|
+
if (currentFolderId === null) return null;
|
|
58
|
+
const pathIds = folderPathIds(folders, currentFolderId);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-wrap items-center gap-0.5">
|
|
62
|
+
<Segment label="All media" target={null} onNavigate={onNavigate} dnd={dnd} isCurrent={false} />
|
|
63
|
+
{pathIds.map((id, i) => (
|
|
64
|
+
<Fragment key={id}>
|
|
65
|
+
<ChevronRight size={12} className="shrink-0 text-base-contrast-light" />
|
|
66
|
+
<Segment
|
|
67
|
+
label={folders[id].name}
|
|
68
|
+
target={id}
|
|
69
|
+
onNavigate={onNavigate}
|
|
70
|
+
dnd={dnd}
|
|
71
|
+
isCurrent={i === pathIds.length - 1}
|
|
72
|
+
/>
|
|
73
|
+
</Fragment>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { Folder, MoreVertical } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { buildFolderDragData } from "../../lib/media-dnd";
|
|
5
|
+
import type { MediaFolder, MediaItem } from "../../media/types";
|
|
6
|
+
import { useFolderDropTarget, useMediaDraggable } from "../../hooks/useMediaFolderDnd";
|
|
7
|
+
import type { MediaFolderDndBindings } from "./MediaFolderTree";
|
|
8
|
+
|
|
9
|
+
export interface MediaFolderTileProps {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
count: number;
|
|
13
|
+
onOpen: (id: string) => void;
|
|
14
|
+
manage: boolean;
|
|
15
|
+
onRename?: (id: string, name: string) => void;
|
|
16
|
+
onDeleteRequest?: (id: string) => void;
|
|
17
|
+
dnd?: MediaFolderDndBindings;
|
|
18
|
+
/** Optional accessible label override for the root element (e.g. "Open folder Logos"). */
|
|
19
|
+
ariaLabel?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function MediaFolderTile({
|
|
24
|
+
id, name, count, onOpen, manage, onRename, onDeleteRequest, dnd, ariaLabel,
|
|
25
|
+
}: MediaFolderTileProps) {
|
|
26
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
27
|
+
const [renaming, setRenaming] = useState(false);
|
|
28
|
+
const [draft, setDraft] = useState(name);
|
|
29
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const emptyFoldersRef = useRef<Record<string, MediaFolder>>({});
|
|
31
|
+
const emptyItemsRef = useRef<Record<string, MediaItem>>({});
|
|
32
|
+
|
|
33
|
+
const drag = useMediaDraggable<HTMLDivElement>({
|
|
34
|
+
enabled: manage && dnd !== undefined && !renaming,
|
|
35
|
+
getDragData: () => buildFolderDragData(id),
|
|
36
|
+
});
|
|
37
|
+
const drop = useFolderDropTarget<HTMLDivElement>({
|
|
38
|
+
targetFolderId: id,
|
|
39
|
+
enabled: dnd !== undefined,
|
|
40
|
+
foldersRef: dnd?.foldersRef ?? emptyFoldersRef,
|
|
41
|
+
itemsByIdRef: dnd?.itemsByIdRef ?? emptyItemsRef,
|
|
42
|
+
onMoveMedia: dnd?.onMoveMedia ?? (() => {}),
|
|
43
|
+
onMoveFolder: dnd?.onMoveFolder ?? (() => {}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Dismiss the kebab menu on any pointerdown outside its container.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!menuOpen) return;
|
|
49
|
+
const handler = (e: PointerEvent) => {
|
|
50
|
+
if (menuRef.current && e.target instanceof Node && !menuRef.current.contains(e.target)) {
|
|
51
|
+
setMenuOpen(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
document.addEventListener("pointerdown", handler);
|
|
55
|
+
return () => document.removeEventListener("pointerdown", handler);
|
|
56
|
+
}, [menuOpen]);
|
|
57
|
+
|
|
58
|
+
const commitRename = () => {
|
|
59
|
+
setRenaming(false);
|
|
60
|
+
const next = draft.trim();
|
|
61
|
+
if (next && next !== name) onRename?.(id, next);
|
|
62
|
+
else setDraft(name);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
// The tile is both a draggable and a drop target — a callback ref fills
|
|
68
|
+
// both hook refs at commit time, BEFORE either hook's registration
|
|
69
|
+
// effect runs (an effect-based sync would run after and never register).
|
|
70
|
+
ref={(el) => { drag.ref.current = el; drop.ref.current = el; }}
|
|
71
|
+
role="button"
|
|
72
|
+
tabIndex={0}
|
|
73
|
+
aria-label={ariaLabel}
|
|
74
|
+
onClick={() => { if (!renaming) onOpen(id); }}
|
|
75
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !renaming) onOpen(id); }}
|
|
76
|
+
className={cn(
|
|
77
|
+
"group/tile relative flex items-center gap-2 rounded-lg border border-base-200 px-3 py-2.5 transition-colors",
|
|
78
|
+
"cursor-pointer hover:border-base-300 hover:bg-base-accent",
|
|
79
|
+
drag.isDragging && "opacity-40",
|
|
80
|
+
drop.isDropTarget && "border-primary ring-2 ring-primary ring-inset",
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<Folder size={16} className="shrink-0 text-base-contrast-light" />
|
|
84
|
+
{renaming ? (
|
|
85
|
+
<input
|
|
86
|
+
autoFocus
|
|
87
|
+
value={draft}
|
|
88
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
89
|
+
onClick={(e) => e.stopPropagation()}
|
|
90
|
+
onKeyDown={(e) => {
|
|
91
|
+
if (e.key === "Enter") commitRename();
|
|
92
|
+
if (e.key === "Escape") { setRenaming(false); setDraft(name); }
|
|
93
|
+
}}
|
|
94
|
+
onBlur={commitRename}
|
|
95
|
+
className="min-w-0 flex-1 rounded border border-base-200 bg-base px-1 py-0.5 text-sm text-base-contrast focus:outline-none focus:ring-1 focus:ring-primary"
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<span className="min-w-0 flex-1 truncate text-sm text-base-contrast">{name}</span>
|
|
99
|
+
)}
|
|
100
|
+
{!renaming && count > 0 && (
|
|
101
|
+
<span className="shrink-0 text-xs text-base-contrast-light">{count}</span>
|
|
102
|
+
)}
|
|
103
|
+
{manage && !renaming && (
|
|
104
|
+
<div ref={menuRef} className="relative shrink-0">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
aria-label={`Folder actions: ${name}`}
|
|
108
|
+
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v); }}
|
|
109
|
+
// Enter/Space keydown bubbles to the tile root BEFORE the button's
|
|
110
|
+
// synthetic click fires — stop it so the tile doesn't open (see
|
|
111
|
+
// the matching chevron fix in MediaFolderTree.tsx).
|
|
112
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }}
|
|
113
|
+
className="rounded p-1 opacity-0 transition-opacity hover:bg-base-200/50 focus:opacity-100 group-hover/tile:opacity-100"
|
|
114
|
+
>
|
|
115
|
+
<MoreVertical size={16} />
|
|
116
|
+
</button>
|
|
117
|
+
{menuOpen && (
|
|
118
|
+
<div className="absolute right-0 top-6 z-20 w-28 rounded-md border border-base-200 bg-base py-1 shadow-lg">
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className="block w-full px-3 py-1.5 text-left text-sm text-base-contrast hover:bg-base-accent"
|
|
122
|
+
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setDraft(name); setRenaming(true); }}
|
|
123
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }}
|
|
124
|
+
>
|
|
125
|
+
Rename
|
|
126
|
+
</button>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="block w-full px-3 py-1.5 text-left text-sm text-red-600 hover:bg-base-accent dark:text-red-400"
|
|
130
|
+
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); onDeleteRequest?.(id); }}
|
|
131
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }}
|
|
132
|
+
>
|
|
133
|
+
Delete
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { ChevronRight, Folder, LayoutGrid } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import type { MediaFolder, MediaItem } from "../../media/types";
|
|
5
|
+
import { buildFolderTree, type FolderTreeNode } from "../../lib/media-folders";
|
|
6
|
+
import { useFolderDropTarget } from "../../hooks/useMediaFolderDnd";
|
|
7
|
+
|
|
8
|
+
export interface MediaFolderDndBindings {
|
|
9
|
+
foldersRef: React.RefObject<Record<string, MediaFolder>>;
|
|
10
|
+
itemsByIdRef: React.RefObject<Record<string, MediaItem>>;
|
|
11
|
+
onMoveMedia: (ids: string[], target: string | null) => void;
|
|
12
|
+
onMoveFolder: (id: string, target: string | null) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MediaFolderTreeProps {
|
|
16
|
+
folders: Record<string, MediaFolder>;
|
|
17
|
+
counts: Record<string, number>;
|
|
18
|
+
totalCount: number;
|
|
19
|
+
currentFolderId: string | null;
|
|
20
|
+
onNavigate: (id: string | null) => void;
|
|
21
|
+
expandedIds: Set<string>;
|
|
22
|
+
onExpand: (id: string) => void;
|
|
23
|
+
onToggleExpand: (id: string) => void;
|
|
24
|
+
dnd?: MediaFolderDndBindings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
function useRowDropTarget(
|
|
29
|
+
targetFolderId: string | null,
|
|
30
|
+
dnd: MediaFolderDndBindings | undefined,
|
|
31
|
+
onDragHover?: () => void,
|
|
32
|
+
) {
|
|
33
|
+
const emptyFoldersRef = useRef<Record<string, MediaFolder>>({});
|
|
34
|
+
const emptyItemsRef = useRef<Record<string, MediaItem>>({});
|
|
35
|
+
return useFolderDropTarget<HTMLDivElement>({
|
|
36
|
+
targetFolderId,
|
|
37
|
+
enabled: dnd !== undefined,
|
|
38
|
+
foldersRef: dnd?.foldersRef ?? emptyFoldersRef,
|
|
39
|
+
itemsByIdRef: dnd?.itemsByIdRef ?? emptyItemsRef,
|
|
40
|
+
onMoveMedia: dnd?.onMoveMedia ?? (() => {}),
|
|
41
|
+
onMoveFolder: dnd?.onMoveFolder ?? (() => {}),
|
|
42
|
+
onDragHover,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rowClass = (active: boolean, isDropTarget: boolean) =>
|
|
47
|
+
cn(
|
|
48
|
+
"flex w-full cursor-pointer items-center gap-1.5 rounded px-2 py-1.5 text-sm transition-colors",
|
|
49
|
+
active ? "bg-primary/10 font-medium text-primary" : "text-base-contrast hover:bg-base-accent",
|
|
50
|
+
isDropTarget && "ring-2 ring-primary ring-inset",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function TreeRow({
|
|
54
|
+
node, depth, props,
|
|
55
|
+
}: { node: FolderTreeNode; depth: number; props: MediaFolderTreeProps }) {
|
|
56
|
+
const { currentFolderId, onNavigate, expandedIds, onExpand, onToggleExpand, counts, dnd } = props;
|
|
57
|
+
const expanded = expandedIds.has(node.id);
|
|
58
|
+
const { ref, isDropTarget } = useRowDropTarget(node.id, dnd, () => onExpand(node.id));
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div>
|
|
62
|
+
<div
|
|
63
|
+
ref={ref}
|
|
64
|
+
role="button"
|
|
65
|
+
tabIndex={0}
|
|
66
|
+
onClick={() => onNavigate(node.id)}
|
|
67
|
+
onKeyDown={(e) => { if (e.key === "Enter") onNavigate(node.id); }}
|
|
68
|
+
className={rowClass(currentFolderId === node.id, isDropTarget)}
|
|
69
|
+
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
70
|
+
>
|
|
71
|
+
{node.children.length > 0 ? (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
aria-label={`${expanded ? "Collapse" : "Expand"} ${node.name}`}
|
|
75
|
+
aria-expanded={expanded}
|
|
76
|
+
onClick={(e) => { e.stopPropagation(); onToggleExpand(node.id); }}
|
|
77
|
+
// Enter/Space keydown bubbles to the row BEFORE the button's
|
|
78
|
+
// synthetic click fires — stop it so the row doesn't navigate.
|
|
79
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }}
|
|
80
|
+
className="shrink-0 rounded p-0.5 hover:bg-base-200"
|
|
81
|
+
>
|
|
82
|
+
<ChevronRight size={12} className={cn("transition-transform", expanded && "rotate-90")} />
|
|
83
|
+
</button>
|
|
84
|
+
) : (
|
|
85
|
+
<span className="w-[17px] shrink-0" />
|
|
86
|
+
)}
|
|
87
|
+
<Folder size={14} className="shrink-0 text-base-contrast-light" />
|
|
88
|
+
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
|
89
|
+
{(counts[node.id] ?? 0) > 0 && (
|
|
90
|
+
<span className="shrink-0 text-xs text-base-contrast-light">{counts[node.id]}</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
{expanded &&
|
|
94
|
+
node.children.map((child) => (
|
|
95
|
+
<TreeRow key={child.id} node={child} depth={depth + 1} props={props} />
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function MediaFolderTree(props: MediaFolderTreeProps) {
|
|
102
|
+
const { folders, totalCount, currentFolderId, onNavigate, dnd } = props;
|
|
103
|
+
const tree = buildFolderTree(folders);
|
|
104
|
+
const root = useRowDropTarget(null, dnd);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<nav aria-label="Media folders" className="flex flex-col gap-0.5">
|
|
108
|
+
<div
|
|
109
|
+
ref={root.ref}
|
|
110
|
+
role="button"
|
|
111
|
+
tabIndex={0}
|
|
112
|
+
onClick={() => onNavigate(null)}
|
|
113
|
+
onKeyDown={(e) => { if (e.key === "Enter") onNavigate(null); }}
|
|
114
|
+
className={rowClass(currentFolderId === null, root.isDropTarget)}
|
|
115
|
+
>
|
|
116
|
+
<LayoutGrid size={14} className="shrink-0 text-base-contrast-light" />
|
|
117
|
+
<span className="min-w-0 flex-1 truncate">All media</span>
|
|
118
|
+
{totalCount > 0 && (
|
|
119
|
+
<span className="shrink-0 text-xs text-base-contrast-light">{totalCount}</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
{tree.map((node) => (
|
|
123
|
+
<TreeRow key={node.id} node={node} depth={0} props={props} />
|
|
124
|
+
))}
|
|
125
|
+
</nav>
|
|
126
|
+
);
|
|
127
|
+
}
|