@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
@@ -14,6 +14,8 @@ export type { SiteFeatureFlags } from "./modules";
14
14
  export { DEFAULT_FEATURE_FLAGS } from "./modules";
15
15
  import type { SiteFeatureFlags } from "./modules";
16
16
  import { CMS_MODULE_KEYS, DEFAULT_FEATURE_FLAGS } from "./modules";
17
+ import { DEFAULT_TIMEZONE, isValidTimezone } from "../cms/timezone";
18
+ import { EMPTY_TYPOGRAPHY, parseSiteTypography, type SiteTypography } from "./typography";
17
19
 
18
20
  /** Official company / contact details. Locale-neutral by design. */
19
21
  export type SiteCompany = {
@@ -87,6 +89,13 @@ export type SiteConfigInput = {
87
89
  backofficePath?: string;
88
90
  /** UI language of the backoffice itself. */
89
91
  backofficeLocale?: string;
92
+ /**
93
+ * IANA timezone the site's editors think in, e.g. "Europe/Stockholm".
94
+ *
95
+ * Scheduled content is stored in UTC and displayed in this zone, so a
96
+ * publish time typed as "10 Sep 18:00" means 18:00 where the site is run.
97
+ */
98
+ timezone?: string;
90
99
  company?: Partial<SiteCompany>;
91
100
  /** Logo artwork defaults; the backoffice may override these. */
92
101
  branding?: Partial<SiteBranding>;
@@ -104,7 +113,11 @@ export type SiteConfig = {
104
113
  defaultLocale: string;
105
114
  backofficePath: string;
106
115
  backofficeLocale: string;
116
+ /** IANA timezone used to display and enter scheduled times. */
117
+ timezone: string;
107
118
  company: SiteCompany;
119
+ /** Menu and footer typography. Defaults keep the theme fonts. */
120
+ typography: SiteTypography;
108
121
  branding: SiteBranding;
109
122
  brand: SiteBrandTokens;
110
123
  features: SiteFeatureFlags;
@@ -130,6 +143,8 @@ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
130
143
  if (!backofficePath.startsWith("/") || backofficePath.endsWith("/")) {
131
144
  fail(`backofficePath "${backofficePath}" must start with "/" and not end with one`);
132
145
  }
146
+ const timezone = input.timezone?.trim() || DEFAULT_TIMEZONE;
147
+ if (!isValidTimezone(timezone)) fail(`timezone "${timezone}" is not a known IANA timezone`);
133
148
  return {
134
149
  key: input.key,
135
150
  name: input.name.trim(),
@@ -138,7 +153,9 @@ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
138
153
  defaultLocale: input.defaultLocale,
139
154
  backofficePath,
140
155
  backofficeLocale: input.backofficeLocale ?? "en",
156
+ timezone,
141
157
  company: { ...EMPTY_COMPANY, ...(input.company ?? {}) },
158
+ typography: EMPTY_TYPOGRAPHY,
142
159
  branding: { ...EMPTY_BRANDING, ...(input.branding ?? {}) },
143
160
  brand: { ...(input.brand ?? {}) },
144
161
  features: { ...DEFAULT_FEATURE_FLAGS, ...(input.features ?? {}) },
@@ -179,6 +196,7 @@ export const OVERRIDABLE_SETTING_KEYS = [
179
196
  "branding",
180
197
  "brand",
181
198
  "features",
199
+ "typography",
182
200
  ] as const satisfies readonly (keyof SiteConfig)[];
183
201
 
184
202
  export type OverridableSettingKey = (typeof OVERRIDABLE_SETTING_KEYS)[number];
@@ -190,6 +208,7 @@ export type SiteSettingOverrides = Partial<{
190
208
  branding: Partial<SiteBranding>;
191
209
  brand: SiteBrandTokens;
192
210
  features: Partial<SiteFeatureFlags>;
211
+ typography: SiteTypography;
193
212
  }>;
194
213
 
195
214
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -238,6 +257,7 @@ export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
238
257
  }
239
258
  out.features = features;
240
259
  }
260
+ if (isRecord(raw["typography"])) out.typography = parseSiteTypography(raw["typography"]);
241
261
  return out;
242
262
  }
243
263
 
@@ -251,6 +271,7 @@ export function resolveSiteConfig(base: SiteConfig, overrides: unknown): SiteCon
251
271
  branding: { ...base.branding, ...(parsed.branding ?? {}) },
252
272
  brand: { ...base.brand, ...(parsed.brand ?? {}) },
253
273
  features: { ...base.features, ...(parsed.features ?? {}) },
274
+ typography: { ...base.typography, ...(parsed.typography ?? {}) },
254
275
  };
255
276
  }
256
277
 
@@ -273,6 +294,17 @@ export function registerSiteConfig(config: SiteConfig): SiteConfig {
273
294
  return config;
274
295
  }
275
296
 
297
+ /**
298
+ * The site's timezone, or the platform default when no config is registered.
299
+ *
300
+ * Scheduling controls need a zone but must not be the thing that crashes a
301
+ * screen: an unregistered config is a startup bug, not a reason for a date
302
+ * field to throw while an editor is typing in it.
303
+ */
304
+ export function siteTimezone(): string {
305
+ return active?.timezone || DEFAULT_TIMEZONE;
306
+ }
307
+
276
308
  /** Read the registered site config. Throws when a site forgot to register. */
277
309
  export function siteConfig(): SiteConfig {
278
310
  if (!active) {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Menu and footer typography.
3
+ *
4
+ * Two independent choices — the top menu and the footer — each either keep the
5
+ * site's theme font (the default, and what every existing site already renders)
6
+ * or name a Google font family.
7
+ *
8
+ * Backwards compatibility is the point of the `"theme"` source: a site with no
9
+ * stored typography, or with a half-filled row, resolves to *no* font-family
10
+ * declaration at all, so the menu and footer keep inheriting exactly the fonts
11
+ * they inherit today. Upgrading changes nothing until an editor chooses a font.
12
+ */
13
+
14
+ /** Where a font comes from. `theme` means "leave the theme alone". */
15
+ export type FontSource = "theme" | "google";
16
+
17
+ /** Theme font tokens from `theme.css`, used as the fallback stack. */
18
+ export type ThemeFontToken = "sans" | "serif";
19
+
20
+ export type FontChoice = {
21
+ /** Defaults to `"theme"` — anything unrecognised is treated as `"theme"`. */
22
+ source?: FontSource;
23
+ /** Google font family, e.g. "DM Serif Display". Required when source is google. */
24
+ family?: string;
25
+ /** Weights to request. Defaults to 400 and 700. */
26
+ weights?: number[];
27
+ /** Theme token appended as the fallback stack behind a Google family. */
28
+ fallback?: ThemeFontToken;
29
+ };
30
+
31
+ export type TypographySlot = "menu" | "footer";
32
+
33
+ /** Slots a site can restyle, and the theme token each one falls back to. */
34
+ export const TYPOGRAPHY_SLOTS: Record<TypographySlot, { label: string; fallback: ThemeFontToken }> =
35
+ {
36
+ menu: { label: "Menu", fallback: "sans" },
37
+ footer: { label: "Footer", fallback: "serif" },
38
+ };
39
+
40
+ export type SiteTypography = Partial<Record<TypographySlot, FontChoice>>;
41
+
42
+ /** No overrides: every slot keeps the theme font. */
43
+ export const EMPTY_TYPOGRAPHY: SiteTypography = {};
44
+
45
+ /** The choice a slot starts from — explicitly "whatever the theme already does". */
46
+ export const THEME_FONT_CHOICE: FontChoice = { source: "theme" };
47
+
48
+ /** Weights requested when a font choice does not name any. */
49
+ export const DEFAULT_FONT_WEIGHTS = [400, 700] as const;
50
+
51
+ function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+
55
+ /**
56
+ * A Google family name safe to put in a stylesheet URL and a CSS declaration.
57
+ * Letters, digits, spaces and a few punctuation marks only — no quotes, no
58
+ * semicolons, nothing that could break out of the `font-family` value.
59
+ */
60
+ export function sanitizeFontFamily(raw: unknown): string {
61
+ if (typeof raw !== "string") return "";
62
+ return raw
63
+ .replace(/[^A-Za-z0-9 +\-_]/g, " ")
64
+ .replace(/\s+/g, " ")
65
+ .trim()
66
+ .slice(0, 60);
67
+ }
68
+
69
+ function sanitizeWeights(raw: unknown): number[] | undefined {
70
+ if (!Array.isArray(raw)) return undefined;
71
+ const weights = Array.from(
72
+ new Set(
73
+ raw
74
+ .map((w) => (typeof w === "number" ? w : Number.parseInt(String(w), 10)))
75
+ .filter((w) => Number.isFinite(w) && w >= 100 && w <= 900 && w % 100 === 0),
76
+ ),
77
+ ).sort((a, b) => a - b);
78
+ return weights.length ? weights : undefined;
79
+ }
80
+
81
+ /** Narrow one untrusted font choice. Anything unusable becomes the theme font. */
82
+ export function parseFontChoice(raw: unknown): FontChoice {
83
+ if (!isRecord(raw)) return { ...THEME_FONT_CHOICE };
84
+ const family = sanitizeFontFamily(raw["family"]);
85
+ const source: FontSource = raw["source"] === "google" && family ? "google" : "theme";
86
+ const out: FontChoice = { source };
87
+ if (family) out.family = family;
88
+ const weights = sanitizeWeights(raw["weights"]);
89
+ if (weights) out.weights = weights;
90
+ if (raw["fallback"] === "sans" || raw["fallback"] === "serif") {
91
+ out.fallback = raw["fallback"];
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Narrow an untrusted typography payload from `site_settings`. */
97
+ export function parseSiteTypography(raw: unknown): SiteTypography {
98
+ if (!isRecord(raw)) return {};
99
+ const out: SiteTypography = {};
100
+ for (const slot of Object.keys(TYPOGRAPHY_SLOTS) as TypographySlot[]) {
101
+ if (raw[slot] !== undefined) out[slot] = parseFontChoice(raw[slot]);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ export type ResolvedFont = {
107
+ /**
108
+ * The `font-family` value to apply, or `undefined` when the theme font should
109
+ * be left exactly as it is. `undefined` is the backwards-compatible answer.
110
+ */
111
+ fontFamily?: string;
112
+ /** Google family to load, when one is configured. */
113
+ googleFamily?: string;
114
+ /** Weights to load with it. */
115
+ weights: number[];
116
+ };
117
+
118
+ /** Resolve one slot's choice into what the renderer and the loader need. */
119
+ export function resolveFontChoice(
120
+ choice: FontChoice | null | undefined,
121
+ slot: TypographySlot,
122
+ ): ResolvedFont {
123
+ const weights = [...DEFAULT_FONT_WEIGHTS];
124
+ if (!choice || choice.source !== "google") return { weights };
125
+ const family = sanitizeFontFamily(choice.family);
126
+ if (!family) return { weights };
127
+ const fallback = choice.fallback ?? TYPOGRAPHY_SLOTS[slot].fallback;
128
+ return {
129
+ fontFamily: `"${family}", var(--font-${fallback})`,
130
+ googleFamily: family,
131
+ weights: choice.weights?.length ? [...choice.weights] : weights,
132
+ };
133
+ }
134
+
135
+ export type ResolvedTypography = Record<TypographySlot, ResolvedFont>;
136
+
137
+ /** Resolve every slot at once. */
138
+ export function resolveTypography(typography: SiteTypography | undefined): ResolvedTypography {
139
+ return {
140
+ menu: resolveFontChoice(typography?.menu, "menu"),
141
+ footer: resolveFontChoice(typography?.footer, "footer"),
142
+ };
143
+ }
@@ -62,6 +62,44 @@ export function isVersionedMediaUrl(search: string | URLSearchParams): boolean {
62
62
  return typeof token === "string" && token.length > 0;
63
63
  }
64
64
 
65
+ /** Sitemaps and feeds: generated from data that changes on a schedule. */
66
+ export function isFeedPath(pathname: string): boolean {
67
+ return /^\/sitemap.*\.xml$/.test(pathname) || pathname === "/rss.xml";
68
+ }
69
+
70
+ /** The longest a feed is cached when nothing at all is scheduled to change. */
71
+ const FEED_MAX_AGE = 3600;
72
+
73
+ /** Still cacheable, but every hit revalidates — an ETag turns that into a 304. */
74
+ const FEED_REVALIDATE: CachePolicy = {
75
+ cacheControl: "public, max-age=0, must-revalidate",
76
+ expiresSeconds: 0,
77
+ kind: "feed",
78
+ };
79
+
80
+ /**
81
+ * Caching for one sitemap or feed response, capped by the next publication
82
+ * boundary.
83
+ *
84
+ * `revalidateAt` is the instant the body stops being true — the earliest
85
+ * `publish_at` still to come or `unpublish_at` still to pass across every page
86
+ * in it, which `listPublishedRoutesCore` hands back with the keys. A cache may
87
+ * shorten that but never outlive it: a scheduled page missing from the sitemap
88
+ * for an hour after it went live is the same defect as publishing it an hour
89
+ * late. Null means nothing is scheduled, so the ordinary hour applies; a
90
+ * boundary already reached means revalidate now.
91
+ */
92
+ export function feedCachePolicy(
93
+ revalidateAt?: number | null,
94
+ now: number = Date.now(),
95
+ ): CachePolicy {
96
+ const budget =
97
+ revalidateAt == null ? FEED_MAX_AGE : Math.floor((revalidateAt - now) / 1000);
98
+ const maxAge = Math.max(0, Math.min(FEED_MAX_AGE, budget));
99
+ if (maxAge <= 0) return { ...FEED_REVALIDATE };
100
+ return { cacheControl: `public, max-age=${maxAge}`, expiresSeconds: maxAge, kind: "feed" };
101
+ }
102
+
65
103
  /** Resolve the caching policy for one request path (+ query string). */
66
104
  export function cachePolicyFor(pathname: string, search = ""): CachePolicy {
67
105
  if (NO_STORE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p))) {
@@ -95,8 +133,17 @@ export function cachePolicyFor(pathname: string, search = ""): CachePolicy {
95
133
  };
96
134
  }
97
135
 
98
- if (/^\/sitemap.*\.xml$/.test(pathname) || pathname === "/rss.xml") {
99
- return { cacheControl: "public, max-age=3600", expiresSeconds: 3600, kind: "feed" };
136
+ if (isFeedPath(pathname)) {
137
+ /*
138
+ * Deliberately not an hour. A sitemap is generated from publication
139
+ * windows, so its contents change on a schedule: an hour of caching can
140
+ * keep a page that went live at noon out of the sitemap until one, and keep
141
+ * a page that expired at noon in it just as long. The path alone cannot say
142
+ * when the next boundary is — only the handler that read the publications
143
+ * knows that, and it says so by calling `feedCachePolicy`. Everything else
144
+ * revalidates, which still costs one cheap 304 rather than a full body.
145
+ */
146
+ return { ...FEED_REVALIDATE };
100
147
  }
101
148
 
102
149
  // Dynamic, server-rendered HTML: always revalidate, never immutable.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Media folders.
3
+ *
4
+ * Storage paths are plain `folder/name.ext` strings — no new table, no new
5
+ * storage API. Uploads default into a `YYYY-MM` folder so the bucket root does
6
+ * not grow without bound; a batch may instead be pinned to a named folder such
7
+ * as `2027-01-Cruice_1_Gallery`.
8
+ *
9
+ * Existing files keep their existing paths: nothing here moves or rewrites
10
+ * anything already uploaded, and `mediaUrl()` has always accepted a prefixed
11
+ * path, so old URLs keep resolving byte for byte.
12
+ */
13
+
14
+ import { instantToZonedLocal } from "../cms/timezone";
15
+
16
+ /** Folders are shallow on purpose — deep trees make the browser unusable. */
17
+ export const MEDIA_FOLDER_MAX_SEGMENTS = 3;
18
+ export const MEDIA_FOLDER_MAX_SEGMENT_LENGTH = 64;
19
+ export const MEDIA_FOLDER_MAX_LENGTH = 160;
20
+
21
+ /** How a batch decides where its files land. */
22
+ export type MediaFolderMode = "auto" | "custom";
23
+
24
+ /** Thrown for folder input that cannot be made safe. */
25
+ export class MediaFolderError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "MediaFolderError";
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The automatic folder for an upload: `YYYY-MM` in the site timezone, so a
34
+ * batch uploaded at 00:30 on the first of the month lands where the editor
35
+ * expects it rather than in the previous month's UTC folder.
36
+ */
37
+ export function monthFolder(now: Date | number = Date.now(), timezone?: string | null): string {
38
+ const instant = typeof now === "number" ? now : now.getTime();
39
+ if (!Number.isFinite(instant)) return "unknown";
40
+ return instantToZonedLocal(instant, timezone).slice(0, 7);
41
+ }
42
+
43
+ /** True for a `YYYY-MM` folder — the ones the library groups by date. */
44
+ export function isMonthFolder(folder: string): boolean {
45
+ return /^\d{4}-(?:0[1-9]|1[0-2])$/.test(folder);
46
+ }
47
+
48
+ /**
49
+ * One path segment, made safe for any object store.
50
+ *
51
+ * Case is preserved (`Cruice_1_Gallery` stays readable) but everything that
52
+ * could escape the bucket or confuse a storage backend is removed: separators,
53
+ * control characters, leading/trailing dots and whitespace, and the `.`/`..`
54
+ * segments that path traversal relies on. Returns `""` when nothing usable is
55
+ * left, which callers treat as invalid rather than as "the bucket root".
56
+ */
57
+ export function sanitizeFolderSegment(input: string): string {
58
+ const cleaned = input
59
+ .normalize("NFD")
60
+ .replace(/[\u0300-\u036f]/g, "")
61
+ // Whitespace becomes a separator before the remaining control characters
62
+ // are dropped, so a pasted line break separates words instead of gluing
63
+ // them together.
64
+ .replace(/\s+/g, "-")
65
+ .replace(/[\u0000-\u001f\u007f]/g, "")
66
+ .replace(/[^A-Za-z0-9._-]/g, "-")
67
+ .replace(/-{2,}/g, "-")
68
+ .replace(/^[.\-]+/, "")
69
+ .replace(/[.\-]+$/, "")
70
+ .slice(0, MEDIA_FOLDER_MAX_SEGMENT_LENGTH);
71
+ if (cleaned === "." || cleaned === "..") return "";
72
+ return cleaned;
73
+ }
74
+
75
+ /**
76
+ * Sanitize a whole folder path. Returns `""` when the input is empty or has no
77
+ * usable segment left — callers decide whether that means "use the automatic
78
+ * folder" or "reject". Never returns a leading `/`, a `..` segment, a drive
79
+ * letter, a protocol, or more than {@link MEDIA_FOLDER_MAX_SEGMENTS} levels.
80
+ */
81
+ export function sanitizeMediaFolder(input: string | null | undefined): string {
82
+ if (typeof input !== "string") return "";
83
+ const segments = input
84
+ .replace(/\\/g, "/")
85
+ .split("/")
86
+ .map(sanitizeFolderSegment)
87
+ .filter(Boolean)
88
+ .slice(0, MEDIA_FOLDER_MAX_SEGMENTS);
89
+ return segments.join("/").slice(0, MEDIA_FOLDER_MAX_LENGTH).replace(/\/+$/, "");
90
+ }
91
+
92
+ /**
93
+ * Sanitize a folder the editor typed, refusing input that cannot be honoured.
94
+ * Silently uploading somewhere other than the folder that was asked for is
95
+ * worse than an error message.
96
+ */
97
+ export function assertMediaFolder(input: string | null | undefined): string {
98
+ const raw = typeof input === "string" ? input.trim() : "";
99
+ if (!raw) return "";
100
+ // A `..` is refused rather than rewritten. "../../gallery" sanitizes to the
101
+ // perfectly safe folder "gallery", but uploading there is not what was asked
102
+ // for, and a traversal attempt deserves an answer rather than a shrug.
103
+ if (/(?:^|\/)\.\.(?:\/|$)/.test(raw.replace(/\\/g, "/"))) {
104
+ throw new MediaFolderError(
105
+ `"${raw}" is not a usable folder name: a folder path cannot contain "..".`,
106
+ );
107
+ }
108
+ const safe = sanitizeMediaFolder(raw);
109
+ if (!safe) {
110
+ throw new MediaFolderError(
111
+ `"${raw}" is not a usable folder name. Use letters, numbers, dashes, dots or underscores.`,
112
+ );
113
+ }
114
+ return safe;
115
+ }
116
+
117
+ /** Everything before the last `/`, or `""` for a file at the bucket root. */
118
+ export function mediaFolderOf(path: string): string {
119
+ const at = path.lastIndexOf("/");
120
+ return at === -1 ? "" : path.slice(0, at);
121
+ }
122
+
123
+ /** Everything after the last `/`. */
124
+ export function mediaBaseName(path: string): string {
125
+ const at = path.lastIndexOf("/");
126
+ return at === -1 ? path : path.slice(at + 1);
127
+ }
128
+
129
+ /** Join an already sanitized folder and a file name into a storage path. */
130
+ export function joinMediaPath(folder: string, name: string): string {
131
+ const dir = folder.replace(/^\/+|\/+$/g, "");
132
+ const file = name.replace(/^\/+/, "");
133
+ return dir ? `${dir}/${file}` : file;
134
+ }
135
+
136
+ /** Label for the folder pickers — the root has to be called something. */
137
+ export const ROOT_FOLDER_LABEL = "Library root";
138
+
139
+ export function folderLabel(folder: string): string {
140
+ return folder === "" ? ROOT_FOLDER_LABEL : folder;
141
+ }
142
+
143
+ export type MediaFolderSummary = {
144
+ /** `""` for the bucket root. */
145
+ folder: string;
146
+ label: string;
147
+ count: number;
148
+ };
149
+
150
+ /**
151
+ * Folder list for the browser: month folders newest first, then named folders
152
+ * alphabetically, then the root. Cheap enough to run on every render — one
153
+ * pass over paths the host already loaded.
154
+ */
155
+ export function summarizeMediaFolders(paths: Iterable<string>): MediaFolderSummary[] {
156
+ const counts = new Map<string, number>();
157
+ for (const path of paths) {
158
+ const folder = mediaFolderOf(path);
159
+ counts.set(folder, (counts.get(folder) ?? 0) + 1);
160
+ }
161
+ return [...counts.entries()]
162
+ .map(([folder, count]) => ({ folder, label: folderLabel(folder), count }))
163
+ .sort((a, b) => {
164
+ if (a.folder === b.folder) return 0;
165
+ if (a.folder === "") return 1;
166
+ if (b.folder === "") return -1;
167
+ const aMonth = isMonthFolder(a.folder);
168
+ const bMonth = isMonthFolder(b.folder);
169
+ if (aMonth !== bMonth) return aMonth ? -1 : 1;
170
+ return aMonth ? b.folder.localeCompare(a.folder) : a.folder.localeCompare(b.folder);
171
+ });
172
+ }
173
+
174
+ /** True when `path` sits in `folder` itself, not in one of its subfolders. */
175
+ export function isInFolder(path: string, folder: string): boolean {
176
+ return mediaFolderOf(path) === folder;
177
+ }
178
+
179
+ /** True when `path` sits in `folder` or anywhere below it. */
180
+ export function isUnderFolder(path: string, folder: string): boolean {
181
+ if (folder === "") return true;
182
+ return path === folder || path.startsWith(`${folder}/`);
183
+ }
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
4
4
  import { optimizeForUpload } from "./optimize";
5
5
  import { pickVideoPoster } from "./pick-video-poster";
6
6
  import { cleanFileName, splitExt, uniqueFileName } from "./filename";
7
+ import { mediaBaseName } from "./folders";
7
8
 
8
9
  export type UploadProgress = { label: string; percent: number } | null;
9
10
 
@@ -14,9 +15,15 @@ export type UploadExistingItem = { name: string };
14
15
  export type UploadBundleFn = (input: {
15
16
  data: {
16
17
  files: Array<{ name: string; contentType: string; bytes: number[] }>;
18
+ /** Destination folder; omitted means the automatic `YYYY-MM` folder. */
19
+ folder?: string;
20
+ timezone?: string;
17
21
  };
18
22
  }) => Promise<{ urls: string[] }>;
19
23
 
24
+ /** Where a batch should land. Omitted folder = the automatic month folder. */
25
+ export type UploadOptions = { folder?: string; timezone?: string };
26
+
20
27
  /**
21
28
  * Shared browser-side media upload (optimize → storage → cache invalidate)
22
29
  * with a coarse percentage so callers can render a progress bar.
@@ -26,6 +33,7 @@ export type UploadBundleFn = (input: {
26
33
  export function useMediaUploadWith(
27
34
  uploadBundle: UploadBundleFn,
28
35
  existing: UploadExistingItem[] = [],
36
+ options: UploadOptions = {},
29
37
  ) {
30
38
  const queryClient = useQueryClient();
31
39
  const [progress, setProgress] = useState<UploadProgress>(null);
@@ -37,10 +45,13 @@ export function useMediaUploadWith(
37
45
  if (!arr.length) return [];
38
46
  setUploading(true);
39
47
  const urls: string[] = [];
48
+ // File names are compared without their folder: the same name in two
49
+ // folders is fine, and the server de-duplicates within the destination.
40
50
  const taken = new Set<string>(
41
51
  existing.flatMap((i) => {
42
- const { base } = splitExt(i.name);
43
- return [i.name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
52
+ const name = mediaBaseName(i.name);
53
+ const { base } = splitExt(name);
54
+ return [name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
44
55
  }),
45
56
  );
46
57
  try {
@@ -72,7 +83,13 @@ export function useMediaUploadWith(
72
83
  bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
73
84
  })),
74
85
  );
75
- const { urls: uploaded } = await uploadBundle({ data: { files: payload } });
86
+ const { urls: uploaded } = await uploadBundle({
87
+ data: {
88
+ files: payload,
89
+ ...(options.folder ? { folder: options.folder } : {}),
90
+ ...(options.timezone ? { timezone: options.timezone } : {}),
91
+ },
92
+ });
76
93
  urls.push(uploaded[0]!);
77
94
  step(1, "Uploading");
78
95
  }
@@ -83,7 +100,7 @@ export function useMediaUploadWith(
83
100
  setProgress(null);
84
101
  }
85
102
  },
86
- [existing, queryClient, uploadBundle],
103
+ [existing, options.folder, options.timezone, queryClient, uploadBundle],
87
104
  );
88
105
 
89
106
  return { upload, uploading, progress };
@@ -1,6 +1,7 @@
1
1
  import type { ComponentType } from "react";
2
2
 
3
3
  import { hasText, resolvedHref } from "../blocks/optional-content";
4
+ import { isVisibleAt, type VisibilitySchedule } from "../cms/visibility-schedule";
4
5
  import type { Locale } from "../i18n/locales";
5
6
  import type { LocalizedString } from "../i18n/types";
6
7
 
@@ -8,6 +9,11 @@ export type NavigationItem = {
8
9
  label: LocalizedString;
9
10
  href: string;
10
11
  children?: NavigationItem[];
12
+ /**
13
+ * Optional visibility window. Absent means "always visible", so every menu
14
+ * written before scheduling existed behaves exactly as it did.
15
+ */
16
+ schedule?: VisibilitySchedule;
11
17
  };
12
18
 
13
19
  export type BackofficeNavigationItem<TIconProps = { className?: string }> = {
@@ -52,19 +58,45 @@ export function navigationPanelId(href: string): string {
52
58
  * only in Swedish still shows in English through the usual fallback, while one
53
59
  * deliberately blanked for English is absent there.
54
60
  *
61
+ * An entry may also carry a visibility window (`schedule`). Outside that window
62
+ * it is dropped here, which is the only place menu visibility is decided — so a
63
+ * hidden entry is absent from the desktop bar, the mobile panel and any other
64
+ * consumer at the same instant. `now` is a parameter rather than a call to
65
+ * `Date.now()` inside, so callers control the clock and every boundary is
66
+ * testable; a caller that renders over time re-renders on the next boundary
67
+ * (see `useScheduleClock`) rather than trusting a cached list.
68
+ *
55
69
  * Order is preserved exactly, so what the editor arranged is what renders.
56
70
  */
57
71
  export function renderableNavigation(
58
72
  items: readonly NavigationItem[] | undefined,
59
73
  locale: Locale,
74
+ now: number | Date = Date.now(),
60
75
  ): NavigationItem[] {
61
76
  return (items ?? [])
62
- .filter((item) => hasText(item.label, locale) && resolvedHref(item.href) !== "")
77
+ .filter(
78
+ (item) =>
79
+ hasText(item.label, locale) &&
80
+ resolvedHref(item.href) !== "" &&
81
+ isVisibleAt(item.schedule, now),
82
+ )
63
83
  .map((item) => {
64
84
  const href = resolvedHref(item.href);
65
- const children = renderableNavigation(item.children, locale);
85
+ const children = renderableNavigation(item.children, locale, now);
66
86
  return children.length > 0
67
87
  ? { ...item, href, children }
68
88
  : { ...item, href, children: undefined };
69
89
  });
70
90
  }
91
+
92
+ /** Every schedule in a navigation tree, for cache and re-render planning. */
93
+ export function navigationSchedules(
94
+ items: readonly NavigationItem[] | undefined,
95
+ ): VisibilitySchedule[] {
96
+ const out: VisibilitySchedule[] = [];
97
+ for (const item of items ?? []) {
98
+ if (item.schedule) out.push(item.schedule);
99
+ out.push(...navigationSchedules(item.children));
100
+ }
101
+ return out;
102
+ }