@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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- 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: {
|
|
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(
|
|
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.
|
|
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 =
|
|
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
|
|
282
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={() =>
|
|
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.
|
|
791
|
-
{item
|
|
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
|
|
987
|
-
: `"${confirmOne
|
|
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.
|
|
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
|
);
|