@drawnagency/primitives 0.1.77 → 0.1.79

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.
Files changed (78) hide show
  1. package/dist/{chunk-B2DLHZKC.js → chunk-2DDWWTRX.js} +1 -1
  2. package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
  3. package/dist/{chunk-63BQQNZO.js → chunk-4XXLH5CL.js} +5 -5
  4. package/dist/{chunk-YP7IHMC3.js → chunk-DR22HM44.js} +1 -1
  5. package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
  6. package/dist/{chunk-RO4E5UJI.js → chunk-FRTJNS5F.js} +7 -7
  7. package/dist/{chunk-SFKYUKVN.js → chunk-QB5Q23G2.js} +1 -1
  8. package/dist/{chunk-SWIHUIFT.js → chunk-RSITF24B.js} +20 -2
  9. package/dist/{chunk-UUCOJL7O.js → chunk-RWMSDDQM.js} +4 -4
  10. package/dist/{chunk-XFDEUK37.js → chunk-UNUJPLZV.js} +1 -1
  11. package/dist/components/editor/index.js +4 -4
  12. package/dist/components/primitives/index.js +5 -5
  13. package/dist/components/sections/brand-guide.js +5 -5
  14. package/dist/components/sections/register-schemas.js +8 -8
  15. package/dist/components/sections/register.js +8 -8
  16. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  17. package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
  18. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
  19. package/dist/components/shell/MediaFolderTile.d.ts +15 -0
  20. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
  21. package/dist/components/shell/MediaFolderTree.d.ts +20 -0
  22. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
  23. package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
  24. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  25. package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
  26. package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
  27. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  28. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  29. package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
  30. package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
  31. package/dist/hooks/useMediaPipeline.d.ts +14 -2
  32. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  33. package/dist/index.js +8 -4
  34. package/dist/lib/corner-radius.d.ts +6 -0
  35. package/dist/lib/corner-radius.d.ts.map +1 -0
  36. package/dist/lib/index.js +2 -2
  37. package/dist/lib/media-dnd.d.ts +26 -0
  38. package/dist/lib/media-dnd.d.ts.map +1 -0
  39. package/dist/lib/media-folders.d.ts +59 -0
  40. package/dist/lib/media-folders.d.ts.map +1 -0
  41. package/dist/media/folder-drop.d.ts +22 -0
  42. package/dist/media/folder-drop.d.ts.map +1 -0
  43. package/dist/media/index.js +6 -2
  44. package/dist/media/types.d.ts +1 -1
  45. package/dist/media/types.d.ts.map +1 -1
  46. package/dist/media/utils.d.ts +7 -0
  47. package/dist/media/utils.d.ts.map +1 -1
  48. package/dist/schemas/index.js +4 -2
  49. package/dist/schemas/media.d.ts +11 -0
  50. package/dist/schemas/media.d.ts.map +1 -1
  51. package/dist/schemas/site-config.d.ts +6 -0
  52. package/dist/schemas/site-config.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/components/brandguide/Colors.tsx +2 -2
  55. package/src/components/chat/ChatPanel.tsx +8 -8
  56. package/src/components/primitives/ResolvedMedia.tsx +1 -1
  57. package/src/components/sections/Button/CTAButton.tsx +1 -1
  58. package/src/components/sections/Media/MediaBlock.tsx +1 -1
  59. package/src/components/shared/Navigation.tsx +5 -5
  60. package/src/components/shell/EditorModal.tsx +7 -7
  61. package/src/components/shell/EditorShell.tsx +23 -4
  62. package/src/components/shell/MediaBreadcrumb.tsx +77 -0
  63. package/src/components/shell/MediaFolderTile.tsx +141 -0
  64. package/src/components/shell/MediaFolderTree.tsx +127 -0
  65. package/src/components/shell/MediaLibraryModal.tsx +611 -229
  66. package/src/components/shell/MoveToFolderDialog.tsx +78 -0
  67. package/src/components/shell/SiteSettingsDisplay.tsx +19 -0
  68. package/src/hooks/useEditorPublish.ts +14 -11
  69. package/src/hooks/useMediaFolderDnd.ts +129 -0
  70. package/src/hooks/useMediaPipeline.ts +162 -6
  71. package/src/lib/corner-radius.ts +22 -0
  72. package/src/lib/media-dnd.ts +62 -0
  73. package/src/lib/media-folders.ts +232 -0
  74. package/src/media/folder-drop.ts +62 -0
  75. package/src/media/types.ts +2 -0
  76. package/src/media/utils.ts +10 -0
  77. package/src/schemas/media.ts +13 -0
  78. package/src/schemas/site-config.ts +4 -0
@@ -28,6 +28,7 @@ import { SectionSkeleton } from "./SectionSkeleton";
28
28
  import { SectionTypePicker } from "./SectionTypePicker";
29
29
  import { ensureSectionsRegistered } from "../sections/register";
30
30
  import { getSection, getAllSections, getSchema } from "../../lib/registry";
31
+ import { cornerRadiusValue } from "../../lib/corner-radius";
31
32
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
32
33
  import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
33
34
 
@@ -158,7 +159,7 @@ export default function EditorShell({
158
159
  const [showSiteSettings, setShowSiteSettings] = useState(false);
159
160
  const [dirtySectionIds, setDirtySectionIds] = useState<Set<string>>(new Set());
160
161
  const [siteConfig, setSiteConfig] = useState<SiteConfig | null>(null);
161
- const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {} });
162
+ const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {}, folders: {} });
162
163
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
163
164
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
164
165
  const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
@@ -192,6 +193,9 @@ export default function EditorShell({
192
193
  // upload-intent failures, direct-to-bucket PUT failures) in the media
193
194
  // library modal, alongside its existing client-side rejection banner.
194
195
  const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
196
+ // Surfaces useMediaPipeline's onUploadNotice (e.g. "N files already exist
197
+ // and were left where they are" on dedup skip) in the media library modal.
198
+ const [uploadNotice, setUploadNotice] = useState<string | null>(null);
195
199
  const [activePageId, setActivePageId] = useState<string | null>(null);
196
200
  const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
197
201
  // Set by recoverConflict when a rebase surfaces overlapping edits; drives the
@@ -317,6 +321,7 @@ export default function EditorShell({
317
321
  setDirtySectionIds,
318
322
  markSectionDirty: persistence.markSectionDirty,
319
323
  onUploadError: (message) => setUploadErrorMessage(message),
324
+ onUploadNotice: setUploadNotice,
320
325
  });
321
326
 
322
327
  const buildStatus = useBuildStatus();
@@ -737,7 +742,7 @@ export default function EditorShell({
737
742
  for (const id of publishedDeletions) {
738
743
  delete images[id];
739
744
  }
740
- return { images };
745
+ return { ...prev, images };
741
746
  });
742
747
  },
743
748
  onShasUpdated: (newSavedSha, newMainSha) => {
@@ -821,6 +826,7 @@ export default function EditorShell({
821
826
  root.style.setProperty("--heading-text-transform", config.uppercaseHeadings ? "uppercase" : "none");
822
827
  root.style.setProperty("--subheading-text-transform", config.uppercaseSubheadings ? "uppercase" : "none");
823
828
  root.style.setProperty("--nav-text-transform", config.uppercaseNavHeadings ? "uppercase" : "none");
829
+ root.style.setProperty("--radius-outer", cornerRadiusValue(config.cornerRadius));
824
830
 
825
831
  if (config.googleFontsUrl) {
826
832
  if (fontLinkRef.current?.href !== config.googleFontsUrl) {
@@ -849,6 +855,7 @@ export default function EditorShell({
849
855
  localStorage.setItem("portal-dark-mode", config.darkMode);
850
856
  localStorage.setItem("portal-primary-color", config.primaryColor);
851
857
  localStorage.setItem("portal-primary-contrast", config.primaryContrast);
858
+ localStorage.setItem("portal-corner-radius", cornerRadiusValue(config.cornerRadius));
852
859
  darkModeEvent.dispatch(config.darkMode);
853
860
  // The nav title lives in a separate island that mounts with a build-time
854
861
  // siteName prop. Push the live (possibly draft) name so editing it in Site
@@ -866,7 +873,7 @@ export default function EditorShell({
866
873
  let loadedSections: LoadedSection[];
867
874
  let loadedIndex: SiteIndex;
868
875
  let loadedConfig: SiteConfig;
869
- let loadedManifest: MediaManifest = { images: {} };
876
+ let loadedManifest: MediaManifest = { images: {}, folders: {} };
870
877
 
871
878
  // Key the content cache on the head of the branch we actually load (the
872
879
  // draft `saved` branch if it exists, else main), provided by the SSR edit
@@ -1056,7 +1063,7 @@ export default function EditorShell({
1056
1063
  }
1057
1064
  }
1058
1065
  const savedManifest = await getMediaManifest();
1059
- setMediaManifest(savedManifest ?? { images: {} });
1066
+ setMediaManifest(savedManifest ?? { images: {}, folders: {} });
1060
1067
  persistence.clearConfigDirty();
1061
1068
  // Discard wiped the Dexie siteIndex row — no unsaved structure remains.
1062
1069
  // Tokenless: discard is unconditional, unlike the save-success clear.
@@ -1522,6 +1529,9 @@ export default function EditorShell({
1522
1529
  }}
1523
1530
  title="Media Library"
1524
1531
  size="large"
1532
+ // Padding lives on the two scroll columns inside MediaLibraryModal
1533
+ // so their scrollbars sit at the pane edges.
1534
+ noPadding
1525
1535
  >
1526
1536
  <MediaLibraryModal
1527
1537
  mode={mediaModalMode}
@@ -1545,6 +1555,15 @@ export default function EditorShell({
1545
1555
  uploadProgress={mediaPipeline.uploadProgress}
1546
1556
  uploadError={uploadErrorMessage}
1547
1557
  onDismissUploadError={() => setUploadErrorMessage(null)}
1558
+ folders={mediaManifest.folders}
1559
+ onCreateFolder={mediaPipeline.handleFolderCreate}
1560
+ onRenameFolder={mediaPipeline.handleFolderRename}
1561
+ onMoveFolder={mediaPipeline.handleFolderMove}
1562
+ onDeleteFolder={mediaPipeline.handleFolderDelete}
1563
+ onMoveMedia={mediaPipeline.handleMediaMove}
1564
+ onFolderUpload={mediaPipeline.handleFolderUpload}
1565
+ uploadNotice={uploadNotice}
1566
+ onDismissUploadNotice={() => setUploadNotice(null)}
1548
1567
  />
1549
1568
  </EditorModal>
1550
1569
  <EditorModal
@@ -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
+ }