@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
@@ -0,0 +1,232 @@
1
+ import type { MediaFolder, MediaItem, MediaManifest } from "../media/types";
2
+
3
+ export interface FolderTreeNode {
4
+ id: string;
5
+ name: string;
6
+ children: FolderTreeNode[];
7
+ }
8
+
9
+ export interface FolderUploadPlan {
10
+ /** New folder entities to merge into manifest.folders, keyed by new id. */
11
+ newFolders: Record<string, MediaFolder>;
12
+ /** Display-folder target per uploaded file (null = root). */
13
+ fileTargets: { file: File; folderId: string | null }[];
14
+ }
15
+
16
+ /** Display-folder id. NOT related to MediaItem.folder (the git storage dir). */
17
+ export function createFolderId(): string {
18
+ const bytes = new Uint8Array(6);
19
+ crypto.getRandomValues(bytes);
20
+ return `f_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
21
+ }
22
+
23
+ /** True when this folder's parent chain loops back to itself. */
24
+ export function isCyclicFolder(folders: Record<string, MediaFolder>, id: string): boolean {
25
+ const max = Object.keys(folders).length;
26
+ let cursor = folders[id]?.parentId ?? null;
27
+ for (let steps = 0; cursor !== null && folders[cursor] && steps <= max; steps++) {
28
+ if (cursor === id) return true;
29
+ cursor = folders[cursor].parentId;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Effective parent for rendering: null (root) when the parent is missing,
36
+ * dangling, or `id` itself sits on a parent cycle. Cycle members re-root,
37
+ * which breaks the loop for their (kept) descendants — corrupt manifests
38
+ * always render, and healMediaManifest persists this view on save.
39
+ */
40
+ export function effectiveParentId(folders: Record<string, MediaFolder>, id: string): string | null {
41
+ const parentId = folders[id]?.parentId ?? null;
42
+ if (parentId === null || !folders[parentId]) return null;
43
+ return isCyclicFolder(folders, id) ? null : parentId;
44
+ }
45
+
46
+ /** Direct children of `parentId`, alphabetically (case-insensitive). */
47
+ export function childFolderIds(folders: Record<string, MediaFolder>, parentId: string | null): string[] {
48
+ return Object.keys(folders)
49
+ .filter((id) => effectiveParentId(folders, id) === parentId)
50
+ .sort((a, b) => folders[a].name.localeCompare(folders[b].name, undefined, { sensitivity: "base" }));
51
+ }
52
+
53
+ export function buildFolderTree(folders: Record<string, MediaFolder>): FolderTreeNode[] {
54
+ const build = (parentId: string | null): FolderTreeNode[] =>
55
+ childFolderIds(folders, parentId).map((id) => ({
56
+ id,
57
+ name: folders[id].name,
58
+ children: build(id),
59
+ }));
60
+ return build(null);
61
+ }
62
+
63
+ /** Names from root to `id` inclusive, cycle-safe. */
64
+ export function folderPathNames(folders: Record<string, MediaFolder>, id: string): string[] {
65
+ const names: string[] = [];
66
+ let cursor: string | null = id;
67
+ while (cursor !== null && folders[cursor]) {
68
+ names.unshift(folders[cursor].name);
69
+ cursor = effectiveParentId(folders, cursor);
70
+ }
71
+ return names;
72
+ }
73
+
74
+ /** Folder ids from root to `id` inclusive, cycle-safe. */
75
+ export function folderPathIds(folders: Record<string, MediaFolder>, id: string): string[] {
76
+ const ids: string[] = [];
77
+ let cursor: string | null = id;
78
+ while (cursor !== null && folders[cursor]) {
79
+ ids.unshift(cursor);
80
+ cursor = effectiveParentId(folders, cursor);
81
+ }
82
+ return ids;
83
+ }
84
+
85
+ /** All folders under `id` (by effective parent), INCLUDING `id` itself. */
86
+ export function descendantFolderIds(folders: Record<string, MediaFolder>, id: string): Set<string> {
87
+ const result = new Set<string>([id]);
88
+ const ids = Object.keys(folders);
89
+ let grew = true;
90
+ while (grew) {
91
+ grew = false;
92
+ for (const fid of ids) {
93
+ if (result.has(fid)) continue;
94
+ const parent = effectiveParentId(folders, fid);
95
+ if (parent !== null && result.has(parent)) {
96
+ result.add(fid);
97
+ grew = true;
98
+ }
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+
104
+ /** May `id` re-parent to `targetParentId`? Blocks self/descendant targets and no-ops. */
105
+ export function canMoveFolder(
106
+ folders: Record<string, MediaFolder>,
107
+ id: string,
108
+ targetParentId: string | null,
109
+ ): boolean {
110
+ if (!folders[id]) return false;
111
+ if (targetParentId === null) return effectiveParentId(folders, id) !== null;
112
+ if (!folders[targetParentId]) return false;
113
+ if (descendantFolderIds(folders, id).has(targetParentId)) return false;
114
+ return effectiveParentId(folders, id) !== targetParentId;
115
+ }
116
+
117
+ /** Trimmed `desired`, auto-suffixed " (2)", " (3)"… on case-insensitive sibling collision. */
118
+ export function uniqueFolderName(
119
+ folders: Record<string, MediaFolder>,
120
+ parentId: string | null,
121
+ desired: string,
122
+ excludeId?: string,
123
+ ): string {
124
+ const base = desired.trim();
125
+ const siblings = new Set(
126
+ childFolderIds(folders, parentId)
127
+ .filter((id) => id !== excludeId)
128
+ .map((id) => folders[id].name.toLowerCase()),
129
+ );
130
+ if (!siblings.has(base.toLowerCase())) return base;
131
+ for (let n = 2; ; n++) {
132
+ const candidate = `${base} (${n})`;
133
+ if (!siblings.has(candidate.toLowerCase())) return candidate;
134
+ }
135
+ }
136
+
137
+ /** The folder an item renders in: its folderId when it points at a real folder, else root. */
138
+ export function itemFolderId(
139
+ folders: Record<string, MediaFolder>,
140
+ item: Pick<MediaItem, "folderId">,
141
+ ): string | null {
142
+ return item.folderId && folders[item.folderId] ? item.folderId : null;
143
+ }
144
+
145
+ /** Recursive item counts per folder id (an item counts toward every ancestor). */
146
+ export function recursiveItemCounts(
147
+ folders: Record<string, MediaFolder>,
148
+ items: MediaItem[],
149
+ ): Record<string, number> {
150
+ const counts: Record<string, number> = {};
151
+ for (const item of items) {
152
+ let cursor = itemFolderId(folders, item);
153
+ const seen = new Set<string>();
154
+ while (cursor !== null && !seen.has(cursor)) {
155
+ seen.add(cursor);
156
+ counts[cursor] = (counts[cursor] ?? 0) + 1;
157
+ cursor = effectiveParentId(folders, cursor);
158
+ }
159
+ }
160
+ return counts;
161
+ }
162
+
163
+ /** Pure heal: dangling folderIds dropped, orphan/cyclic folders re-rooted. */
164
+ export function healMediaManifest(manifest: MediaManifest): MediaManifest {
165
+ const folders: Record<string, MediaFolder> = {};
166
+ for (const [id, f] of Object.entries(manifest.folders)) {
167
+ folders[id] = { ...f, parentId: effectiveParentId(manifest.folders, id) };
168
+ }
169
+ const images: MediaManifest["images"] = {};
170
+ for (const [id, entry] of Object.entries(manifest.images)) {
171
+ if (entry.folderId && !folders[entry.folderId]) {
172
+ const { folderId: _dropped, ...rest } = entry;
173
+ images[id] = rest;
174
+ } else {
175
+ images[id] = entry;
176
+ }
177
+ }
178
+ return { ...manifest, images, folders };
179
+ }
180
+
181
+ /**
182
+ * Save-time manifest assembly: merge uploaded pending items, apply deletions,
183
+ * preserve every other manifest key (folders!), then heal.
184
+ */
185
+ export function assembleSavedManifest(
186
+ manifest: MediaManifest,
187
+ uploadedItems: MediaItem[],
188
+ deletedIds: string[],
189
+ ): MediaManifest {
190
+ const images = { ...manifest.images };
191
+ for (const entry of uploadedItems) images[entry.id] = entry;
192
+ for (const id of deletedIds) delete images[id];
193
+ return healMediaManifest({ ...manifest, images });
194
+ }
195
+
196
+ /**
197
+ * Map webkitRelativePath-style paths onto folder entities under
198
+ * `currentFolderId`, merging with existing folders by case-insensitive name.
199
+ */
200
+ export function planFolderUpload(
201
+ files: { file: File; relativePath: string }[],
202
+ folders: Record<string, MediaFolder>,
203
+ currentFolderId: string | null,
204
+ newId: () => string = createFolderId,
205
+ ): FolderUploadPlan {
206
+ const newFolders: Record<string, MediaFolder> = {};
207
+ // (parentId, lowercased name) -> folder id, seeded from existing folders.
208
+ const byName = new Map<string, string>();
209
+ for (const [fid, f] of Object.entries(folders)) {
210
+ byName.set(`${effectiveParentId(folders, fid)}/${f.name.toLowerCase()}`, fid);
211
+ }
212
+ const ensureDir = (segments: string[]): string | null => {
213
+ let parent = currentFolderId;
214
+ for (const segment of segments) {
215
+ const key = `${parent}/${segment.toLowerCase()}`;
216
+ let fid = byName.get(key);
217
+ if (!fid) {
218
+ fid = newId();
219
+ newFolders[fid] = { name: segment, parentId: parent };
220
+ byName.set(key, fid);
221
+ }
222
+ parent = fid;
223
+ }
224
+ return parent;
225
+ };
226
+ const fileTargets = files.map(({ file, relativePath }) => {
227
+ const segments = relativePath.split("/").filter(Boolean);
228
+ segments.pop(); // filename
229
+ return { file, folderId: ensureDir(segments) };
230
+ });
231
+ return { newFolders, fileTargets };
232
+ }
@@ -0,0 +1,62 @@
1
+ export interface DroppedFile {
2
+ file: File;
3
+ relativePath: string;
4
+ }
5
+
6
+ /** Structural subset of FileSystemEntry — lets tests fake the tree. */
7
+ export interface EntryLike {
8
+ isFile: boolean;
9
+ isDirectory: boolean;
10
+ name: string;
11
+ fullPath: string;
12
+ file?: (cb: (f: File) => void, err: (e: unknown) => void) => void;
13
+ createReader?: () => {
14
+ readEntries: (cb: (entries: EntryLike[]) => void, err: (e: unknown) => void) => void;
15
+ };
16
+ }
17
+
18
+ const readAll = (reader: NonNullable<ReturnType<NonNullable<EntryLike["createReader"]>>>): Promise<EntryLike[]> =>
19
+ new Promise((resolve, reject) => {
20
+ const all: EntryLike[] = [];
21
+ const step = () =>
22
+ reader.readEntries((batch) => {
23
+ if (batch.length === 0) return resolve(all);
24
+ all.push(...batch);
25
+ // Defer rather than call step() synchronously: readers return results
26
+ // in batches and loop until empty, but a synchronous re-entrant call
27
+ // here would run before any state a caller sets after this callback
28
+ // returns (e.g. a "served" flag in a synchronous test fake) — causing
29
+ // infinite recursion. Real FileSystemDirectoryReader impls are async
30
+ // anyway, so the microtask tick is free in the browser.
31
+ queueMicrotask(step);
32
+ }, reject);
33
+ step();
34
+ });
35
+
36
+ export async function traverseEntries(entries: EntryLike[]): Promise<DroppedFile[]> {
37
+ const out: DroppedFile[] = [];
38
+ for (const entry of entries) {
39
+ if (entry.isFile && entry.file) {
40
+ const file = await new Promise<File>((resolve, reject) => entry.file!(resolve, reject));
41
+ out.push({ file, relativePath: entry.fullPath.replace(/^\//, "") });
42
+ } else if (entry.isDirectory && entry.createReader) {
43
+ out.push(...(await traverseEntries(await readAll(entry.createReader()))));
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Dropped-payload traversal. Returns null when nothing in the drop is a
51
+ * directory — the caller should fall back to the plain flat-file path.
52
+ */
53
+ export async function collectDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[] | null> {
54
+ const entries: EntryLike[] = [];
55
+ for (const item of Array.from(dataTransfer.items)) {
56
+ const getEntry = (item as DataTransferItem & { webkitGetAsEntry?: () => EntryLike | null }).webkitGetAsEntry;
57
+ const entry = typeof getEntry === "function" ? getEntry.call(item) : null;
58
+ if (entry) entries.push(entry);
59
+ }
60
+ if (!entries.some((e) => e.isDirectory)) return null;
61
+ return traverseEntries(entries);
62
+ }
@@ -3,10 +3,12 @@ export {
3
3
  MediaItemSchema,
4
4
  ImageManifestSchema,
5
5
  MediaConfigSchema,
6
+ MediaFolderSchema,
6
7
  type Variant,
7
8
  type MediaItem,
8
9
  type MediaManifest,
9
10
  type MediaConfig,
11
+ type MediaFolder,
10
12
  } from "../schemas/media";
11
13
 
12
14
  import type { MediaItem } from "../schemas/media";
@@ -1,4 +1,5 @@
1
1
  import { slugifyPageSlug } from "../lib/pages";
2
+ import type { MediaItem } from "./types";
2
3
 
3
4
  export async function hashFileBuffer(buffer: ArrayBuffer): Promise<string> {
4
5
  if (typeof crypto !== "undefined" && crypto.subtle) {
@@ -65,3 +66,12 @@ export function formatFileSize(bytes: number): string {
65
66
  if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
66
67
  return `${bytes}B`;
67
68
  }
69
+
70
+ /**
71
+ * Display name for a media item. v2 batch-rename seam: will read a future
72
+ * optional `displayName` field with originalName fallback — UI must call this
73
+ * instead of touching originalName directly.
74
+ */
75
+ export function displayName(item: Pick<MediaItem, "originalName">): string {
76
+ return item.originalName;
77
+ }
@@ -8,6 +8,15 @@ export const VariantSchema = z.object({
8
8
 
9
9
  export type Variant = z.infer<typeof VariantSchema>;
10
10
 
11
+ // Display folders for the media library. Pure metadata: a folder assignment
12
+ // never moves files in git (that's MediaItem.folder, the storage dir).
13
+ export const MediaFolderSchema = z.object({
14
+ name: z.string().min(1),
15
+ parentId: z.string().nullable(), // null = root
16
+ });
17
+
18
+ export type MediaFolder = z.infer<typeof MediaFolderSchema>;
19
+
11
20
  export const MediaItemSchema = z.object({
12
21
  id: z.string(),
13
22
  hash: z.string(),
@@ -29,12 +38,16 @@ export const MediaItemSchema = z.object({
29
38
  url: z.string().optional(),
30
39
  // Seconds; videos only. Informational (library UI).
31
40
  duration: z.number().positive().optional(),
41
+ // Display folder (MediaFolderSchema key). Absent = root/unfiled. NOT the
42
+ // git storage dir — that's `folder` above, which never changes.
43
+ folderId: z.string().optional(),
32
44
  });
33
45
 
34
46
  export type MediaItem = z.infer<typeof MediaItemSchema>;
35
47
 
36
48
  export const ImageManifestSchema = z.object({
37
49
  images: z.record(z.string(), MediaItemSchema),
50
+ folders: z.record(z.string(), MediaFolderSchema).default({}),
38
51
  });
39
52
 
40
53
  export type MediaManifest = z.infer<typeof ImageManifestSchema>;