@drawnagency/primitives 0.1.77 → 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.
Files changed (63) 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-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
  4. package/dist/{chunk-RO4E5UJI.js → chunk-BAQYZSFF.js} +6 -6
  5. package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
  6. package/dist/{chunk-YP7IHMC3.js → chunk-K4JD6FD3.js} +1 -1
  7. package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
  8. package/dist/{chunk-63BQQNZO.js → chunk-NS3UTJ3E.js} +3 -3
  9. package/dist/{chunk-UUCOJL7O.js → chunk-UA6OCJJK.js} +2 -2
  10. package/dist/{chunk-XFDEUK37.js → chunk-Z5IYWUW3.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/hooks/useEditorPublish.d.ts.map +1 -1
  28. package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
  29. package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
  30. package/dist/hooks/useMediaPipeline.d.ts +14 -2
  31. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  32. package/dist/index.js +8 -4
  33. package/dist/lib/index.js +2 -2
  34. package/dist/lib/media-dnd.d.ts +26 -0
  35. package/dist/lib/media-dnd.d.ts.map +1 -0
  36. package/dist/lib/media-folders.d.ts +59 -0
  37. package/dist/lib/media-folders.d.ts.map +1 -0
  38. package/dist/media/folder-drop.d.ts +22 -0
  39. package/dist/media/folder-drop.d.ts.map +1 -0
  40. package/dist/media/index.js +6 -2
  41. package/dist/media/types.d.ts +1 -1
  42. package/dist/media/types.d.ts.map +1 -1
  43. package/dist/media/utils.d.ts +7 -0
  44. package/dist/media/utils.d.ts.map +1 -1
  45. package/dist/schemas/index.js +4 -2
  46. package/dist/schemas/media.d.ts +11 -0
  47. package/dist/schemas/media.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/components/shell/EditorShell.tsx +20 -4
  50. package/src/components/shell/MediaBreadcrumb.tsx +77 -0
  51. package/src/components/shell/MediaFolderTile.tsx +141 -0
  52. package/src/components/shell/MediaFolderTree.tsx +127 -0
  53. package/src/components/shell/MediaLibraryModal.tsx +611 -229
  54. package/src/components/shell/MoveToFolderDialog.tsx +78 -0
  55. package/src/hooks/useEditorPublish.ts +14 -11
  56. package/src/hooks/useMediaFolderDnd.ts +129 -0
  57. package/src/hooks/useMediaPipeline.ts +162 -6
  58. package/src/lib/media-dnd.ts +62 -0
  59. package/src/lib/media-folders.ts +232 -0
  60. package/src/media/folder-drop.ts +62 -0
  61. package/src/media/types.ts +2 -0
  62. package/src/media/utils.ts +10 -0
  63. package/src/schemas/media.ts +13 -0
package/dist/lib/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  loadStaticSiteContent,
8
8
  mergeSiteContent,
9
9
  safeRedirect
10
- } from "../chunk-XFDEUK37.js";
10
+ } from "../chunk-Z5IYWUW3.js";
11
11
  import {
12
12
  createEvent,
13
13
  darkModeEvent,
@@ -33,7 +33,7 @@ import {
33
33
  audiencePasses
34
34
  } from "../chunk-4YEOTFVU.js";
35
35
  import "../chunk-TZQPOR5A.js";
36
- import "../chunk-SWIHUIFT.js";
36
+ import "../chunk-4SIWBWKZ.js";
37
37
  import {
38
38
  clearRegistry,
39
39
  createRegistry,
@@ -0,0 +1,26 @@
1
+ import type { MediaFolder, MediaItem } from "../media/types";
2
+ export interface MediaDragData {
3
+ dragType: "media-item";
4
+ mediaIds: string[];
5
+ [key: string | symbol]: unknown;
6
+ }
7
+ export interface MediaFolderDragData {
8
+ dragType: "media-folder";
9
+ folderId: string;
10
+ [key: string | symbol]: unknown;
11
+ }
12
+ export interface MediaFolderDropData {
13
+ dropType: "media-folder";
14
+ targetFolderId: string | null;
15
+ [key: string | symbol]: unknown;
16
+ }
17
+ export declare function buildMediaDragData(mediaIds: string[]): MediaDragData;
18
+ export declare function buildFolderDragData(folderId: string): MediaFolderDragData;
19
+ export declare function buildFolderDropData(targetFolderId: string | null): MediaFolderDropData;
20
+ export declare function isMediaDragData(d: Record<string | symbol, unknown>): d is MediaDragData;
21
+ export declare function isFolderDragData(d: Record<string | symbol, unknown>): d is MediaFolderDragData;
22
+ /** True when at least one dragged item would actually change folder. */
23
+ export declare function canDropMediaInto(drag: MediaDragData, targetFolderId: string | null, folders: Record<string, MediaFolder>, itemsById: Record<string, MediaItem>): boolean;
24
+ /** Folder drops delegate to the tree-integrity guard (self/descendant/no-op). */
25
+ export declare function canDropFolderInto(drag: MediaFolderDragData, targetFolderId: string | null, folders: Record<string, MediaFolder>): boolean;
26
+ //# sourceMappingURL=media-dnd.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media-dnd.d.ts","sourceRoot":"","sources":["../../src/lib/media-dnd.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAG7D,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,cAAc,CAAC;IACzB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CACjC;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,aAAa,CAEpE;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,mBAAmB,CAEzE;AAED,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,GAAG,mBAAmB,CAEtF;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,aAAa,CAEvF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,mBAAmB,CAE9F;AAED,wEAAwE;AACxE,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GACnC,OAAO,CAKT;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,mBAAmB,EACzB,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GACnC,OAAO,CAET"}
@@ -0,0 +1,59 @@
1
+ import type { MediaFolder, MediaItem, MediaManifest } from "../media/types";
2
+ export interface FolderTreeNode {
3
+ id: string;
4
+ name: string;
5
+ children: FolderTreeNode[];
6
+ }
7
+ export interface FolderUploadPlan {
8
+ /** New folder entities to merge into manifest.folders, keyed by new id. */
9
+ newFolders: Record<string, MediaFolder>;
10
+ /** Display-folder target per uploaded file (null = root). */
11
+ fileTargets: {
12
+ file: File;
13
+ folderId: string | null;
14
+ }[];
15
+ }
16
+ /** Display-folder id. NOT related to MediaItem.folder (the git storage dir). */
17
+ export declare function createFolderId(): string;
18
+ /** True when this folder's parent chain loops back to itself. */
19
+ export declare function isCyclicFolder(folders: Record<string, MediaFolder>, id: string): boolean;
20
+ /**
21
+ * Effective parent for rendering: null (root) when the parent is missing,
22
+ * dangling, or `id` itself sits on a parent cycle. Cycle members re-root,
23
+ * which breaks the loop for their (kept) descendants — corrupt manifests
24
+ * always render, and healMediaManifest persists this view on save.
25
+ */
26
+ export declare function effectiveParentId(folders: Record<string, MediaFolder>, id: string): string | null;
27
+ /** Direct children of `parentId`, alphabetically (case-insensitive). */
28
+ export declare function childFolderIds(folders: Record<string, MediaFolder>, parentId: string | null): string[];
29
+ export declare function buildFolderTree(folders: Record<string, MediaFolder>): FolderTreeNode[];
30
+ /** Names from root to `id` inclusive, cycle-safe. */
31
+ export declare function folderPathNames(folders: Record<string, MediaFolder>, id: string): string[];
32
+ /** Folder ids from root to `id` inclusive, cycle-safe. */
33
+ export declare function folderPathIds(folders: Record<string, MediaFolder>, id: string): string[];
34
+ /** All folders under `id` (by effective parent), INCLUDING `id` itself. */
35
+ export declare function descendantFolderIds(folders: Record<string, MediaFolder>, id: string): Set<string>;
36
+ /** May `id` re-parent to `targetParentId`? Blocks self/descendant targets and no-ops. */
37
+ export declare function canMoveFolder(folders: Record<string, MediaFolder>, id: string, targetParentId: string | null): boolean;
38
+ /** Trimmed `desired`, auto-suffixed " (2)", " (3)"… on case-insensitive sibling collision. */
39
+ export declare function uniqueFolderName(folders: Record<string, MediaFolder>, parentId: string | null, desired: string, excludeId?: string): string;
40
+ /** The folder an item renders in: its folderId when it points at a real folder, else root. */
41
+ export declare function itemFolderId(folders: Record<string, MediaFolder>, item: Pick<MediaItem, "folderId">): string | null;
42
+ /** Recursive item counts per folder id (an item counts toward every ancestor). */
43
+ export declare function recursiveItemCounts(folders: Record<string, MediaFolder>, items: MediaItem[]): Record<string, number>;
44
+ /** Pure heal: dangling folderIds dropped, orphan/cyclic folders re-rooted. */
45
+ export declare function healMediaManifest(manifest: MediaManifest): MediaManifest;
46
+ /**
47
+ * Save-time manifest assembly: merge uploaded pending items, apply deletions,
48
+ * preserve every other manifest key (folders!), then heal.
49
+ */
50
+ export declare function assembleSavedManifest(manifest: MediaManifest, uploadedItems: MediaItem[], deletedIds: string[]): MediaManifest;
51
+ /**
52
+ * Map webkitRelativePath-style paths onto folder entities under
53
+ * `currentFolderId`, merging with existing folders by case-insensitive name.
54
+ */
55
+ export declare function planFolderUpload(files: {
56
+ file: File;
57
+ relativePath: string;
58
+ }[], folders: Record<string, MediaFolder>, currentFolderId: string | null, newId?: () => string): FolderUploadPlan;
59
+ //# sourceMappingURL=media-folders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media-folders.d.ts","sourceRoot":"","sources":["../../src/lib/media-folders.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ export interface DroppedFile {
2
+ file: File;
3
+ relativePath: string;
4
+ }
5
+ /** Structural subset of FileSystemEntry — lets tests fake the tree. */
6
+ export interface EntryLike {
7
+ isFile: boolean;
8
+ isDirectory: boolean;
9
+ name: string;
10
+ fullPath: string;
11
+ file?: (cb: (f: File) => void, err: (e: unknown) => void) => void;
12
+ createReader?: () => {
13
+ readEntries: (cb: (entries: EntryLike[]) => void, err: (e: unknown) => void) => void;
14
+ };
15
+ }
16
+ export declare function traverseEntries(entries: EntryLike[]): Promise<DroppedFile[]>;
17
+ /**
18
+ * Dropped-payload traversal. Returns null when nothing in the drop is a
19
+ * directory — the caller should fall back to the plain flat-file path.
20
+ */
21
+ export declare function collectDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[] | null>;
22
+ //# sourceMappingURL=folder-drop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folder-drop.d.ts","sourceRoot":"","sources":["../../src/media/folder-drop.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,IAAI,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,KAAK,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;IAClE,YAAY,CAAC,EAAE,MAAM;QACnB,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,KAAK,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;KACtF,CAAC;CACH;AAoBD,wBAAsB,eAAe,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAWlF;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,CASnG"}
@@ -5,6 +5,7 @@ import {
5
5
  ProcessingQueue,
6
6
  displayFilename,
7
7
  displayFilenameExt,
8
+ displayName,
8
9
  formatFileSize,
9
10
  generateVideoPoster,
10
11
  getMediaProvider,
@@ -15,14 +16,15 @@ import {
15
16
  resolveMedia,
16
17
  sanitizeMediaName,
17
18
  setMediaProvider
18
- } from "../chunk-S55OZ7F2.js";
19
+ } from "../chunk-3LNKOLZP.js";
19
20
  import "../chunk-4YEOTFVU.js";
20
21
  import {
21
22
  ImageManifestSchema,
22
23
  MediaConfigSchema,
24
+ MediaFolderSchema,
23
25
  MediaItemSchema,
24
26
  VariantSchema
25
- } from "../chunk-SWIHUIFT.js";
27
+ } from "../chunk-4SIWBWKZ.js";
26
28
  import "../chunk-JSBRDJBE.js";
27
29
  export {
28
30
  BAKED_MEDIA_KEYS,
@@ -30,11 +32,13 @@ export {
30
32
  ImageManifestSchema,
31
33
  MIME_TO_EXT,
32
34
  MediaConfigSchema,
35
+ MediaFolderSchema,
33
36
  MediaItemSchema,
34
37
  ProcessingQueue,
35
38
  VariantSchema,
36
39
  displayFilename,
37
40
  displayFilenameExt,
41
+ displayName,
38
42
  formatFileSize,
39
43
  generateVideoPoster,
40
44
  getMediaProvider,
@@ -1,4 +1,4 @@
1
- export { VariantSchema, MediaItemSchema, ImageManifestSchema, MediaConfigSchema, type Variant, type MediaItem, type MediaManifest, type MediaConfig, } from "../schemas/media";
1
+ export { VariantSchema, MediaItemSchema, ImageManifestSchema, MediaConfigSchema, MediaFolderSchema, type Variant, type MediaItem, type MediaManifest, type MediaConfig, type MediaFolder, } from "../schemas/media";
2
2
  export type MediaKind = "image" | "animated" | "video";
3
3
  export interface MediaFile {
4
4
  buffer: ArrayBuffer;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/media/types.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,KAAK,OAAO,EACZ,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,WAAW,GACjB,MAAM,kBAAkB,CAAC;AAI1B,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,OAAO,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,WAAW,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,KAAK,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,KAAK,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,OAAO,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,gBAAgB,GAAG,aAAa,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/media/types.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,OAAO,EACZ,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,WAAW,GACjB,MAAM,kBAAkB,CAAC;AAI1B,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,OAAO,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,WAAW,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,KAAK,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,KAAK,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,OAAO,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,gBAAgB,GAAG,aAAa,CAAC"}
@@ -1,3 +1,4 @@
1
+ import type { MediaItem } from "./types";
1
2
  export declare function hashFileBuffer(buffer: ArrayBuffer): Promise<string>;
2
3
  export declare function sanitizeMediaName(name: string): string;
3
4
  export declare const MIME_TO_EXT: Record<string, string>;
@@ -6,4 +7,10 @@ export declare function mimeToExt(mime: string): string;
6
7
  export declare function displayFilenameExt(mime: string): string;
7
8
  export declare function displayFilename(originalName: string, mimeType: string): string;
8
9
  export declare function formatFileSize(bytes: number): string;
10
+ /**
11
+ * Display name for a media item. v2 batch-rename seam: will read a future
12
+ * optional `displayName` field with originalName fallback — UI must call this
13
+ * instead of touching originalName directly.
14
+ */
15
+ export declare function displayName(item: Pick<MediaItem, "originalName">): string;
9
16
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/media/utils.ts"],"names":[],"mappings":"AAEA,wBAAsB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAqBzE;AAKD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAI9C,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAK9C,CAAC;AAEF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM9E;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpD"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/media/utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,wBAAsB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAqBzE;AAKD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAI9C,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAK9C,CAAC;AAEF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM9E;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,GAAG,MAAM,CAEzE"}
@@ -3,7 +3,7 @@ import {
3
3
  AudienceNameSchema,
4
4
  MediaGridOptionsSchema,
5
5
  slugifyAudienceName
6
- } from "../chunk-SFKYUKVN.js";
6
+ } from "../chunk-MK4POMLZ.js";
7
7
  import {
8
8
  AudienceSchema,
9
9
  RoleSchema,
@@ -28,6 +28,7 @@ import {
28
28
  ImageManifestSchema,
29
29
  IndexSchema,
30
30
  MediaConfigSchema,
31
+ MediaFolderSchema,
31
32
  MediaItemSchema,
32
33
  MediaReferenceSchema,
33
34
  PageSchema,
@@ -39,7 +40,7 @@ import {
39
40
  TextLineSchema,
40
41
  VariantSchema,
41
42
  normalizeSiteIndex
42
- } from "../chunk-SWIHUIFT.js";
43
+ } from "../chunk-4SIWBWKZ.js";
43
44
  import "../chunk-VKAGMEKE.js";
44
45
  import "../chunk-NSCT3AMV.js";
45
46
  import "../chunk-JSBRDJBE.js";
@@ -57,6 +58,7 @@ export {
57
58
  LinkTargetSchema,
58
59
  LinkValueSchema,
59
60
  MediaConfigSchema,
61
+ MediaFolderSchema,
60
62
  MediaGridOptionsSchema,
61
63
  MediaItemSchema,
62
64
  MediaReferenceSchema,
@@ -5,6 +5,11 @@ export declare const VariantSchema: z.ZodObject<{
5
5
  size: z.ZodNumber;
6
6
  }, z.core.$strip>;
7
7
  export type Variant = z.infer<typeof VariantSchema>;
8
+ export declare const MediaFolderSchema: z.ZodObject<{
9
+ name: z.ZodString;
10
+ parentId: z.ZodNullable<z.ZodString>;
11
+ }, z.core.$strip>;
12
+ export type MediaFolder = z.infer<typeof MediaFolderSchema>;
8
13
  export declare const MediaItemSchema: z.ZodObject<{
9
14
  id: z.ZodString;
10
15
  hash: z.ZodString;
@@ -31,6 +36,7 @@ export declare const MediaItemSchema: z.ZodObject<{
31
36
  }>>;
32
37
  url: z.ZodOptional<z.ZodString>;
33
38
  duration: z.ZodOptional<z.ZodNumber>;
39
+ folderId: z.ZodOptional<z.ZodString>;
34
40
  }, z.core.$strip>;
35
41
  export type MediaItem = z.infer<typeof MediaItemSchema>;
36
42
  export declare const ImageManifestSchema: z.ZodObject<{
@@ -60,7 +66,12 @@ export declare const ImageManifestSchema: z.ZodObject<{
60
66
  }>>;
61
67
  url: z.ZodOptional<z.ZodString>;
62
68
  duration: z.ZodOptional<z.ZodNumber>;
69
+ folderId: z.ZodOptional<z.ZodString>;
63
70
  }, z.core.$strip>>;
71
+ folders: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
72
+ name: z.ZodString;
73
+ parentId: z.ZodNullable<z.ZodString>;
74
+ }, z.core.$strip>>>;
64
75
  }, z.core.$strip>;
65
76
  export type MediaManifest = z.infer<typeof ImageManifestSchema>;
66
77
  export declare const MediaConfigSchema: z.ZodObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/schemas/media.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,aAAa;;;;iBAIxB,CAAC;AAEH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;iBAqB1B,CAAC;AAEH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAE9B,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,iBAAiB;;;;;iBAO5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/schemas/media.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,aAAa;;;;iBAIxB,CAAC;AAEH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAIpD,eAAO,MAAM,iBAAiB;;;iBAG5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwB1B,CAAC;AAEH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAG9B,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,iBAAiB;;;;;iBAO5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -158,7 +158,7 @@ export default function EditorShell({
158
158
  const [showSiteSettings, setShowSiteSettings] = useState(false);
159
159
  const [dirtySectionIds, setDirtySectionIds] = useState<Set<string>>(new Set());
160
160
  const [siteConfig, setSiteConfig] = useState<SiteConfig | null>(null);
161
- const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {} });
161
+ const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {}, folders: {} });
162
162
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
163
163
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
164
164
  const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
@@ -192,6 +192,9 @@ export default function EditorShell({
192
192
  // upload-intent failures, direct-to-bucket PUT failures) in the media
193
193
  // library modal, alongside its existing client-side rejection banner.
194
194
  const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
195
+ // Surfaces useMediaPipeline's onUploadNotice (e.g. "N files already exist
196
+ // and were left where they are" on dedup skip) in the media library modal.
197
+ const [uploadNotice, setUploadNotice] = useState<string | null>(null);
195
198
  const [activePageId, setActivePageId] = useState<string | null>(null);
196
199
  const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
197
200
  // Set by recoverConflict when a rebase surfaces overlapping edits; drives the
@@ -317,6 +320,7 @@ export default function EditorShell({
317
320
  setDirtySectionIds,
318
321
  markSectionDirty: persistence.markSectionDirty,
319
322
  onUploadError: (message) => setUploadErrorMessage(message),
323
+ onUploadNotice: setUploadNotice,
320
324
  });
321
325
 
322
326
  const buildStatus = useBuildStatus();
@@ -737,7 +741,7 @@ export default function EditorShell({
737
741
  for (const id of publishedDeletions) {
738
742
  delete images[id];
739
743
  }
740
- return { images };
744
+ return { ...prev, images };
741
745
  });
742
746
  },
743
747
  onShasUpdated: (newSavedSha, newMainSha) => {
@@ -866,7 +870,7 @@ export default function EditorShell({
866
870
  let loadedSections: LoadedSection[];
867
871
  let loadedIndex: SiteIndex;
868
872
  let loadedConfig: SiteConfig;
869
- let loadedManifest: MediaManifest = { images: {} };
873
+ let loadedManifest: MediaManifest = { images: {}, folders: {} };
870
874
 
871
875
  // Key the content cache on the head of the branch we actually load (the
872
876
  // draft `saved` branch if it exists, else main), provided by the SSR edit
@@ -1056,7 +1060,7 @@ export default function EditorShell({
1056
1060
  }
1057
1061
  }
1058
1062
  const savedManifest = await getMediaManifest();
1059
- setMediaManifest(savedManifest ?? { images: {} });
1063
+ setMediaManifest(savedManifest ?? { images: {}, folders: {} });
1060
1064
  persistence.clearConfigDirty();
1061
1065
  // Discard wiped the Dexie siteIndex row — no unsaved structure remains.
1062
1066
  // Tokenless: discard is unconditional, unlike the save-success clear.
@@ -1522,6 +1526,9 @@ export default function EditorShell({
1522
1526
  }}
1523
1527
  title="Media Library"
1524
1528
  size="large"
1529
+ // Padding lives on the two scroll columns inside MediaLibraryModal
1530
+ // so their scrollbars sit at the pane edges.
1531
+ noPadding
1525
1532
  >
1526
1533
  <MediaLibraryModal
1527
1534
  mode={mediaModalMode}
@@ -1545,6 +1552,15 @@ export default function EditorShell({
1545
1552
  uploadProgress={mediaPipeline.uploadProgress}
1546
1553
  uploadError={uploadErrorMessage}
1547
1554
  onDismissUploadError={() => setUploadErrorMessage(null)}
1555
+ folders={mediaManifest.folders}
1556
+ onCreateFolder={mediaPipeline.handleFolderCreate}
1557
+ onRenameFolder={mediaPipeline.handleFolderRename}
1558
+ onMoveFolder={mediaPipeline.handleFolderMove}
1559
+ onDeleteFolder={mediaPipeline.handleFolderDelete}
1560
+ onMoveMedia={mediaPipeline.handleMediaMove}
1561
+ onFolderUpload={mediaPipeline.handleFolderUpload}
1562
+ uploadNotice={uploadNotice}
1563
+ onDismissUploadNotice={() => setUploadNotice(null)}
1548
1564
  />
1549
1565
  </EditorModal>
1550
1566
  <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
+ }