@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0

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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -1,4 +1,4 @@
1
- import { useRef, useState, type ComponentType, type ReactNode } from "react";
1
+ import { useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
2
2
  import {
3
3
  Upload,
4
4
  Trash2,
@@ -13,12 +13,24 @@ import {
13
13
  RotateCcw,
14
14
  Tags,
15
15
  Replace,
16
+ Folder,
17
+ Clock,
16
18
  } from "lucide-react";
17
19
  import { toast } from "sonner";
18
20
 
19
21
  import { optimizeForUpload } from "../media/optimize";
20
22
  import { pickVideoPoster } from "../media/pick-video-poster";
21
23
  import { cleanFileName, splitExt, uniqueFileName } from "../media/filename";
24
+ import {
25
+ folderLabel,
26
+ isInFolder,
27
+ mediaFolderOf,
28
+ mediaBaseName,
29
+ monthFolder,
30
+ sanitizeMediaFolder,
31
+ summarizeMediaFolders,
32
+ type MediaFolderMode,
33
+ } from "../media/folders";
22
34
  import { hasMediaMeta, lookupMediaMeta, type MediaMetaMap } from "../media/media-meta";
23
35
  import type { MediaUsage, MediaUsageReport, MediaImportResult } from "../media/usage-types";
24
36
  import type { MediaLibraryItem, MediaTrashItem } from "../media/library-types";
@@ -34,6 +46,8 @@ export type MediaLibraryDeps = {
34
46
  usage: MediaUsageReport | undefined;
35
47
  /** Whether the signed-in user may permanently delete / run maintenance. */
36
48
  isSuperadmin: boolean;
49
+ /** Site timezone, so the automatic `YYYY-MM` folder rolls over locally. */
50
+ timezone?: string;
37
51
  metaMap: MediaMetaMap | undefined;
38
52
 
39
53
  /* Host server calls */
@@ -42,7 +56,12 @@ export type MediaLibraryDeps = {
42
56
  purgeMedia: (input: { data: { id: string } }) => Promise<unknown>;
43
57
  emptyTrash: () => Promise<{ purged: number }>;
44
58
  uploadMediaBundle: (input: {
45
- data: { files: { name: string; contentType: string; bytes: number[] }[] };
59
+ data: {
60
+ files: { name: string; contentType: string; bytes: number[] }[];
61
+ /** Destination folder; absent means the automatic `YYYY-MM` folder. */
62
+ folder?: string;
63
+ timezone?: string;
64
+ };
46
65
  }) => Promise<unknown>;
47
66
  importUrlToMedia: (input: { data: { url: string } }) => Promise<MediaImportResult>;
48
67
  /** Invalidate library / trash / usage queries after a mutation. */
@@ -137,6 +156,23 @@ function monthLabel(key: string) {
137
156
 
138
157
  const PAGE_SIZE = 50;
139
158
 
159
+ /**
160
+ * How many uploads the Recent tab shows before "Load more". Deliberately a
161
+ * constant rather than a setting — 50 fills the grid without a slow first
162
+ * paint, and pagination covers everything beyond it.
163
+ */
164
+ const RECENT_PAGE_SIZE = 50;
165
+
166
+ /** File name without its folder — cards and downloads show this, not the path. */
167
+ function displayName(item: { name: string }): string {
168
+ return mediaBaseName(item.name);
169
+ }
170
+
171
+ /** Newest first, undated last, host order preserved within a tie. */
172
+ function byNewest(a: MediaLibraryItem, b: MediaLibraryItem) {
173
+ return (itemDate(b)?.getTime() ?? 0) - (itemDate(a)?.getTime() ?? 0);
174
+ }
175
+
140
176
  /**
141
177
  * Keep the uploaded filename (SEO-relevant) — slugified, extension stripped so
142
178
  * the optimizer appends the final one. Collisions get `-2`, `-3`, … never a timestamp.
@@ -160,6 +196,7 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
160
196
  trashLoading,
161
197
  usage,
162
198
  isSuperadmin: isSuper,
199
+ timezone,
163
200
  metaMap,
164
201
  deleteMedia: del,
165
202
  restoreMedia: restore,
@@ -183,7 +220,12 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
183
220
  const [percent, setPercent] = useState(0);
184
221
  const [filter, setFilter] = useState("");
185
222
  const [month, setMonth] = useState("all");
186
- const [visible, setVisible] = useState(PAGE_SIZE);
223
+ const [visible, setVisible] = useState(RECENT_PAGE_SIZE);
224
+ const [browse, setBrowse] = useState<"recent" | "folders">("recent");
225
+ const [folder, setFolder] = useState("");
226
+ const [scopeSearch, setScopeSearch] = useState(false);
227
+ const [folderMode, setFolderMode] = useState<MediaFolderMode>("auto");
228
+ const [customFolder, setCustomFolder] = useState("");
187
229
 
188
230
  const [usageFor, setUsageFor] = useState<{ title: string; usages: MediaUsage[] } | null>(null);
189
231
  const [importingUrl, setImportingUrl] = useState<string | null>(null);
@@ -255,31 +297,59 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
255
297
  const months = [...monthCounts.keys()].sort((a, b) => b.localeCompare(a));
256
298
  const undatedCount = items.length - [...monthCounts.values()].reduce((a, b) => a + b, 0);
257
299
 
300
+ // Folders present in the library, month folders newest first.
301
+ const folders = useMemo(() => summarizeMediaFolders(items.map((i) => i.path)), [items]);
302
+ // The selected folder may vanish (last file deleted) — fall back to the first.
303
+ const activeFolder = folders.some((f) => f.folder === folder)
304
+ ? folder
305
+ : (folders[0]?.folder ?? "");
306
+ const query = filter.trim().toLowerCase();
307
+ const searching = query.length > 0;
308
+ // Search spans every folder unless the editor explicitly scopes it.
309
+ const folderScoped = browse === "folders" && (!searching || scopeSearch);
310
+
258
311
  const filtered = items.filter((i) => {
259
- if (!i.name.toLowerCase().includes(filter.toLowerCase())) return false;
312
+ if (query && !i.path.toLowerCase().includes(query)) return false;
313
+ if (folderScoped && !isInFolder(i.path, activeFolder)) return false;
260
314
  if (month === "all") return true;
261
315
  if (month === "unknown") return monthKey(i) === null;
262
316
  return monthKey(i) === month;
263
317
  });
318
+ // Recent is a view over the whole library; folder browsing keeps the host's
319
+ // order so a gallery folder reads the way it was uploaded.
320
+ const ordered = browse === "recent" ? [...filtered].sort(byNewest) : filtered;
264
321
  const imageCount = filtered.filter((i) => (i.contentType ?? "").startsWith("image/")).length;
265
322
  const videoCount = filtered.filter((i) => (i.contentType ?? "").startsWith("video/")).length;
266
323
  const otherCount = filtered.length - imageCount - videoCount;
267
- const shown = filtered.slice(0, visible);
324
+ const shown = ordered.slice(0, visible);
325
+ const resetPaging = () => setVisible(browse === "recent" ? RECENT_PAGE_SIZE : PAGE_SIZE);
268
326
 
269
327
  const usageMap = usage?.byPath ?? {};
270
328
  const externals = usage?.external ?? [];
271
329
 
330
+ // Where this batch lands. Empty means the server's automatic `YYYY-MM`.
331
+ const customDest = sanitizeMediaFolder(customFolder);
332
+ const uploadDest = folderMode === "custom" ? customDest : "";
333
+ const customInvalid = folderMode === "custom" && customFolder.trim() !== "" && !customDest;
334
+
272
335
  const onUpload = async (files: FileList | null) => {
273
336
  if (!files?.length) return;
337
+ if (folderMode === "custom" && !customDest) {
338
+ toast.error("Enter a folder name, or switch back to the automatic month folder.");
339
+ if (fileInput.current) fileInput.current.value = "";
340
+ return;
341
+ }
274
342
  setUploading(true);
275
343
  try {
276
344
  let totalSaved = 0;
277
345
  const arr = Array.from(files);
278
346
  // Names already in the library (plus the ones taken during this batch).
347
+ // Compared without folders: the server de-duplicates per destination.
279
348
  const taken = new Set<string>(
280
349
  items.flatMap((i) => {
281
- const { base } = splitExt(i.name);
282
- return [i.name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
350
+ const name = displayName(i);
351
+ const { base } = splitExt(name);
352
+ return [name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
283
353
  }),
284
354
  );
285
355
  const stepPct = (idx: number, frac: number) =>
@@ -306,7 +376,13 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
306
376
  bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
307
377
  })),
308
378
  );
309
- await uploadBundle({ data: { files: payload } });
379
+ await uploadBundle({
380
+ data: {
381
+ files: payload,
382
+ ...(uploadDest ? { folder: uploadDest } : {}),
383
+ ...(timezone ? { timezone } : {}),
384
+ },
385
+ });
310
386
  totalSaved += Math.max(0, file.size - bundle.primary.blob.size);
311
387
  const extraTotal = bundle.extras.length || 1;
312
388
  let extraDone = 0;
@@ -317,7 +393,17 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
317
393
  stepPct(idx, 1);
318
394
  }
319
395
  const savedTxt = totalSaved > 1024 ? ` (saved ${humanBytes(totalSaved)})` : "";
320
- toast.success(`Uploaded ${arr.length} file${arr.length > 1 ? "s" : ""}${savedTxt}`);
396
+ const dest = uploadDest || monthFolder(Date.now(), timezone);
397
+ toast.success(
398
+ `Uploaded ${arr.length} file${arr.length > 1 ? "s" : ""} to ${dest}${savedTxt}`,
399
+ );
400
+ // Show the destination straight away instead of leaving the editor on a
401
+ // Recent list that may not include a back-dated folder.
402
+ if (uploadDest) {
403
+ setBrowse("folders");
404
+ setFolder(uploadDest);
405
+ setVisible(PAGE_SIZE);
406
+ }
321
407
  await invalidateLibrary();
322
408
  } catch (e) {
323
409
  toast.error(e instanceof Error ? e.message : "Upload failed");
@@ -366,7 +452,7 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
366
452
  await del({ data: { path: item.path } });
367
453
  ok += 1;
368
454
  } catch {
369
- failed.push(item.name);
455
+ failed.push(displayName(item));
370
456
  }
371
457
  }
372
458
  await refreshAll();
@@ -392,7 +478,7 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
392
478
  for (const item of selectedItems) {
393
479
  const a = document.createElement("a");
394
480
  a.href = item.url;
395
- a.download = item.name;
481
+ a.download = displayName(item);
396
482
  a.target = "_blank";
397
483
  a.rel = "noopener";
398
484
  document.body.appendChild(a);
@@ -436,7 +522,7 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
436
522
  value={month}
437
523
  onChange={(e) => {
438
524
  setMonth(e.target.value);
439
- setVisible(PAGE_SIZE);
525
+ resetPaging();
440
526
  }}
441
527
  className="border border-ink/15 px-2 py-1.5 text-sm bg-white text-ink focus:outline-none focus:border-sea"
442
528
  title="Filter by upload month"
@@ -456,10 +542,11 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
456
542
  <input
457
543
  type="text"
458
544
  value={filter}
459
- placeholder="Search…"
545
+ placeholder="Search all folders…"
546
+ aria-label="Search media"
460
547
  onChange={(e) => {
461
548
  setFilter(e.target.value);
462
- setVisible(PAGE_SIZE);
549
+ resetPaging();
463
550
  }}
464
551
  className="border border-ink/15 pl-8 pr-3 py-1.5 text-sm bg-white focus:outline-none focus:border-sea"
465
552
  />
@@ -614,6 +701,130 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
614
701
  </span>
615
702
  </div>
616
703
 
704
+ {/* Browse: newest uploads, or one folder at a time. */}
705
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border border-ink/10 bg-white px-3 py-2 text-[0.72rem]">
706
+ <div className="flex items-center gap-1" role="group" aria-label="Browse media by">
707
+ {(["recent", "folders"] as const).map((b) => (
708
+ <button
709
+ key={b}
710
+ type="button"
711
+ aria-pressed={browse === b}
712
+ onClick={() => {
713
+ setBrowse(b);
714
+ setVisible(b === "recent" ? RECENT_PAGE_SIZE : PAGE_SIZE);
715
+ }}
716
+ className={[
717
+ "inline-flex items-center gap-1.5 border px-2.5 py-1 text-[0.65rem] uppercase tracking-widest",
718
+ browse === b
719
+ ? "border-sea bg-sea text-white"
720
+ : "border-ink/15 text-ink/60 hover:text-ink",
721
+ ].join(" ")}
722
+ >
723
+ {b === "recent" ? <Clock className="size-3" /> : <Folder className="size-3" />}
724
+ {b === "recent" ? "Recent" : "Folders"}
725
+ </button>
726
+ ))}
727
+ </div>
728
+
729
+ {browse === "folders" ? (
730
+ <>
731
+ <label htmlFor="media-folder" className="text-ink/55">
732
+ Folder
733
+ </label>
734
+ <select
735
+ id="media-folder"
736
+ value={activeFolder}
737
+ onChange={(e) => {
738
+ setFolder(e.target.value);
739
+ setVisible(PAGE_SIZE);
740
+ }}
741
+ className="border border-ink/15 px-2 py-1 text-sm bg-white text-ink focus:outline-none focus:border-sea"
742
+ >
743
+ {folders.length === 0 ? <option value="">No folders yet</option> : null}
744
+ {folders.map((f) => (
745
+ <option key={f.folder} value={f.folder}>
746
+ {f.label} ({f.count})
747
+ </option>
748
+ ))}
749
+ </select>
750
+ {searching ? (
751
+ <label className="inline-flex cursor-pointer items-center gap-1.5 text-ink/70">
752
+ <input
753
+ type="checkbox"
754
+ checked={scopeSearch}
755
+ onChange={(e) => {
756
+ setScopeSearch(e.target.checked);
757
+ setVisible(PAGE_SIZE);
758
+ }}
759
+ className="size-3.5 accent-[color:var(--color-sea,#2ac4ea)]"
760
+ />
761
+ Search only {folderLabel(activeFolder)}
762
+ </label>
763
+ ) : null}
764
+ </>
765
+ ) : null}
766
+
767
+ <span className="ml-auto text-ink/55">
768
+ {searching
769
+ ? folderScoped
770
+ ? `Searching ${folderLabel(activeFolder)}`
771
+ : "Searching every folder"
772
+ : browse === "recent"
773
+ ? "Newest uploads first"
774
+ : folderLabel(activeFolder)}
775
+ </span>
776
+ </div>
777
+
778
+ {/* Where the next batch lands. Existing files are never moved. */}
779
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border border-ink/10 bg-white px-3 py-2 text-[0.72rem] text-ink/70">
780
+ <span className="uppercase tracking-[0.18em] text-ink/45">Upload to</span>
781
+ <div className="flex items-center gap-1" role="group" aria-label="Upload destination">
782
+ {(["auto", "custom"] as const).map((m) => (
783
+ <button
784
+ key={m}
785
+ type="button"
786
+ aria-pressed={folderMode === m}
787
+ onClick={() => setFolderMode(m)}
788
+ className={[
789
+ "border px-2.5 py-1 text-[0.65rem] uppercase tracking-widest",
790
+ folderMode === m
791
+ ? "border-sea bg-sea text-white"
792
+ : "border-ink/15 text-ink/60 hover:text-ink",
793
+ ].join(" ")}
794
+ >
795
+ {m === "auto" ? "Automatic month" : "Custom folder"}
796
+ </button>
797
+ ))}
798
+ </div>
799
+ {folderMode === "custom" ? (
800
+ <input
801
+ type="text"
802
+ value={customFolder}
803
+ onChange={(e) => setCustomFolder(e.target.value)}
804
+ placeholder="2027-01-Cruice_1_Gallery"
805
+ aria-label="Custom upload folder"
806
+ aria-invalid={customInvalid || undefined}
807
+ aria-describedby="media-upload-dest"
808
+ className={[
809
+ "border px-2 py-1 text-sm bg-white focus:outline-none focus:border-sea",
810
+ customInvalid ? "border-red-500" : "border-ink/15",
811
+ ].join(" ")}
812
+ />
813
+ ) : null}
814
+ <span
815
+ id="media-upload-dest"
816
+ className={customInvalid ? "text-red-600" : "text-ink/55"}
817
+ >
818
+ {folderMode === "auto"
819
+ ? `New uploads go to ${monthFolder(Date.now(), timezone)}/. Files already in the library stay where they are.`
820
+ : customInvalid
821
+ ? "Use letters, numbers, dashes, dots or underscores."
822
+ : customDest
823
+ ? `New uploads go to ${customDest}/.`
824
+ : "Name the folder for this batch."}
825
+ </span>
826
+ </div>
827
+
617
828
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 border border-ink/10 bg-white px-3 py-2 text-[0.72rem] text-ink/70">
618
829
  <span className="uppercase tracking-[0.18em] text-ink/45">
619
830
  {month === "all"
@@ -752,7 +963,7 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
752
963
  <FocalImage
753
964
  src={bustUrl(item.url)}
754
965
  responsive={false}
755
- alt={item.name}
966
+ alt={displayName(item)}
756
967
  className="w-full h-full object-cover"
757
968
  />
758
969
  ) : isVideo ? (
@@ -768,7 +979,9 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
768
979
  )}
769
980
  <button
770
981
  type="button"
771
- onClick={() => inUse && setUsageFor({ title: item.name, usages })}
982
+ onClick={() =>
983
+ inUse && setUsageFor({ title: displayName(item), usages })
984
+ }
772
985
  disabled={!inUse}
773
986
  title={
774
987
  inUse
@@ -787,9 +1000,14 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
787
1000
  </button>
788
1001
  </div>
789
1002
  <div className="p-2.5 flex-1 flex flex-col gap-1.5">
790
- <div className="text-xs text-ink truncate" title={item.name}>
791
- {item.name}
1003
+ <div className="text-xs text-ink truncate" title={item.path}>
1004
+ {displayName(item)}
792
1005
  </div>
1006
+ {mediaFolderOf(item.path) ? (
1007
+ <div className="text-[0.65rem] text-ink/40 truncate">
1008
+ {mediaFolderOf(item.path)}
1009
+ </div>
1010
+ ) : null}
793
1011
  <div className="text-[0.65rem] uppercase tracking-widest text-ink/45">
794
1012
  {humanBytes(item.size)} · {isoDate(item) ?? "Unknown date"}
795
1013
  </div>
@@ -983,8 +1201,8 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
983
1201
  title="Move file to the bin?"
984
1202
  description={
985
1203
  (usageMap[confirmOne.path]?.length ?? 0) > 0
986
- ? `"${confirmOne.name}" is still used in ${usageMap[confirmOne.path]!.length} place(s) — it will disappear from the site. You can restore it from the bin.`
987
- : `"${confirmOne.name}" will be moved to the bin. You can restore it later.`
1204
+ ? `"${displayName(confirmOne)}" is still used in ${usageMap[confirmOne.path]!.length} place(s) — it will disappear from the site. You can restore it from the bin.`
1205
+ : `"${displayName(confirmOne)}" will be moved to the bin. You can restore it later.`
988
1206
  }
989
1207
  confirmLabel="Move to bin"
990
1208
  danger
@@ -1015,7 +1233,7 @@ export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
1015
1233
  ? `These files are used in ${selectedUses} place(s) on the site and will disappear there. You can restore them from the bin.`
1016
1234
  : "The selected files will be moved to the bin. You can restore them later."
1017
1235
  }
1018
- details={selectedItems.map((i) => i.name)}
1236
+ details={selectedItems.map((i) => i.path)}
1019
1237
  confirmLabel="Move to bin"
1020
1238
  danger
1021
1239
  busy={bulkBusy}
@@ -4,11 +4,19 @@ import { RotateCcw, Save } from "lucide-react";
4
4
  import { toast } from "sonner";
5
5
 
6
6
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
7
+ import type { SiteTypography, TypographySlot } from "../config/typography";
7
8
  import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
9
+ import { TypographyPanel } from "./TypographyPanel";
8
10
 
9
11
  /** The two site-wide content sections this screen edits. */
10
12
  export type NavigationSectionKey = "nav" | "footer";
11
13
 
14
+ /** Which typography slot belongs to which section on this screen. */
15
+ const TYPOGRAPHY_SLOT: Record<NavigationSectionKey, TypographySlot> = {
16
+ nav: "menu",
17
+ footer: "footer",
18
+ };
19
+
12
20
  /** Host-provided data and actions for the navigation & footer screen. */
13
21
  export type NavigationDeps = {
14
22
  /** Saved overrides keyed by section, as stored in the CMS. */
@@ -28,6 +36,24 @@ export type NavigationDeps = {
28
36
  };
29
37
  }) => Promise<unknown>;
30
38
  refresh: () => Promise<unknown> | void;
39
+ /**
40
+ * Menu and footer typography, edited beside the section it restyles.
41
+ *
42
+ * Optional: a host that has not wired the site-settings functions into this
43
+ * screen simply does not get the panels, and nothing else changes.
44
+ */
45
+ typography?: {
46
+ /** In effect now — code defaults with any stored override applied. */
47
+ value: SiteTypography;
48
+ /**
49
+ * Stored overrides only. Saving merges into this, so writing the menu font
50
+ * never freezes the footer's code default into the database.
51
+ */
52
+ overrides?: SiteTypography;
53
+ /** Persist the complete typography override object. */
54
+ save: (next: SiteTypography) => Promise<unknown>;
55
+ refresh?: () => Promise<unknown> | void;
56
+ };
31
57
  };
32
58
 
33
59
  function Editor({
@@ -42,6 +68,8 @@ function Editor({
42
68
  description: string;
43
69
  }) {
44
70
  const { overrides, site, defaults, SectionForm, saveSection, refresh } = deps;
71
+ const typography = deps.typography;
72
+ const slot = TYPOGRAPHY_SLOT[sectionKey];
45
73
 
46
74
  const current = useMemo(() => {
47
75
  const override = overrides?.[sectionKey];
@@ -128,6 +156,21 @@ function Editor({
128
156
  Reset to default
129
157
  </button>
130
158
  </div>
159
+
160
+ {/* Typography is a site setting rather than section content, so it has
161
+ its own save and is not held by this section's content lock. */}
162
+ {typography ? (
163
+ <div className="mt-8 border-t border-ink/10 pt-6">
164
+ <TypographyPanel
165
+ slot={slot}
166
+ value={typography.value[slot]}
167
+ onSave={async (choice) => {
168
+ await typography.save({ ...(typography.overrides ?? {}), [slot]: choice });
169
+ await typography.refresh?.();
170
+ }}
171
+ />
172
+ </div>
173
+ ) : null}
131
174
  </div>
132
175
  </section>
133
176
  );