smoodly 0.0.24 → 0.0.25
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/README.md +182 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +20 -6
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +1 -1
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/ops-impl.js +4 -0
- package/dist/admin/ops.d.ts +9 -0
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +23 -1
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +48 -7
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +96 -73
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +11 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +368 -203
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +6 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +4 -0
- package/dist/supabase-store.d.ts +3 -0
- package/dist/supabase-store.js +23 -0
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -495,6 +495,65 @@ after both are on the registry. From the repo root:
|
|
|
495
495
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
496
496
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
497
497
|
|
|
498
|
+
## Follow-ups (logged 2026-09-26, browser suite)
|
|
499
|
+
|
|
500
|
+
- Drag and drop has no browser test (spec §4): the outline, the pages
|
|
501
|
+
tree and list rows reorder by real pointer drags; the first plan that
|
|
502
|
+
touches `useSortable` adds them.
|
|
503
|
+
- The suite runs Chromium only; the admin's WebKit and Firefox
|
|
504
|
+
behaviour is unverified.
|
|
505
|
+
- Three `window.confirm` calls remain outside the confirm dialog (the
|
|
506
|
+
router's leave guard, `ui/confirm.ts`'s fallback) and are not exercised.
|
|
507
|
+
- The media test uploads a starter photo under a suffixed name rather
|
|
508
|
+
than a fixture file, so `e2e/fixtures/` from the spec does not exist.
|
|
509
|
+
- ListView rows that are not clickable (Members) carry no role; the
|
|
510
|
+
members test finds its row by `.sm-table__body > div`. A table role
|
|
511
|
+
set (`table`/`row`/`cell`) on ListView would give every list a proper
|
|
512
|
+
locator.
|
|
513
|
+
- Publishing a global the site layout reads (the footer) serves the old
|
|
514
|
+
layout on the first public request made right after the publish returns;
|
|
515
|
+
every request after it, and a first request made ~3 s later, is fresh.
|
|
516
|
+
Pages do not show it (an unpublish answers 404 at once). Seen on
|
|
517
|
+
`next dev`; `e2e/globals.spec.ts` polls the home page for it. Find out
|
|
518
|
+
whether `updateTag` over a layout's `getGlobal` read expires late, and
|
|
519
|
+
whether `next start` behaves the same.
|
|
520
|
+
- Fixed in the same session: an inserted section seeded an unsampled
|
|
521
|
+
optional object as `{}`, which publish then validated as present — an
|
|
522
|
+
untouched Image and text `link` refused the publish. The insert now
|
|
523
|
+
leaves it absent and validation reads a blank optional object as absent.
|
|
524
|
+
|
|
525
|
+
## Follow-ups (logged 2026-09-26, schema migrations spec)
|
|
526
|
+
|
|
527
|
+
- Existing databases (every install so far is ours; spec 2026-09-26 §7):
|
|
528
|
+
a database created before version 1 has the schema but not the table.
|
|
529
|
+
One time, in the SQL editor or psql:
|
|
530
|
+
`create table if not exists "smoodly_meta" ("key" text primary key, "value" text not null);`
|
|
531
|
+
`alter table "smoodly_meta" enable row level security;`
|
|
532
|
+
`insert into "smoodly_meta" ("key", "value") values ('schema_version', '1') on conflict ("key") do update set "value" = excluded."value";`
|
|
533
|
+
then re-apply the `smoodly_read` policy on `smoodly_meta` from
|
|
534
|
+
`migrations/20260926120000_smoodly_v1.sql` (a `drop … if exists` /
|
|
535
|
+
`create policy` pair). The hand-applied statement lists below stay as
|
|
536
|
+
history; from version 2 on, every change is a file.
|
|
537
|
+
- Drafting version N+1: `npx tsx scripts/emit-migration.ts` prints the
|
|
538
|
+
whole schema plus the upsert; keep only the delta, name the file with
|
|
539
|
+
the current UTC stamp and `_smoodly_v<N+1>.sql`, bump `SCHEMA_VERSION`,
|
|
540
|
+
run `npm run db:migrate` at the root so the repo copy follows, and
|
|
541
|
+
`npm run check:migrations`. Additive only; a contraction waits for the
|
|
542
|
+
compatibility window and carries `-- smoodly:contraction`.
|
|
543
|
+
- The views of a removed collection stay (spec 2026-09-26 §3); a
|
|
544
|
+
`drop view` is the developer's.
|
|
545
|
+
- `smoodly migrate` never pushes; a `--push` flag if the two-step
|
|
546
|
+
proves annoying.
|
|
547
|
+
- The CLI loads `smoodly/collections.ts` through tsx; a project that
|
|
548
|
+
registers collections elsewhere passes `--collections <file>`.
|
|
549
|
+
- The site's one warning fires from the Supabase page store's first
|
|
550
|
+
`resolvePath`, once per store instance; a site that never locates (a
|
|
551
|
+
pure `q` reader) never warns, and a long-running server that was
|
|
552
|
+
current when it booted stays quiet — the admin notice is the real
|
|
553
|
+
signal.
|
|
554
|
+
- A Deploying guide (nav lists it, unwritten) should show
|
|
555
|
+
`npx smoodly migrate --check` as a build step.
|
|
556
|
+
|
|
498
557
|
## Follow-ups (logged 2026-09-25, docs session)
|
|
499
558
|
|
|
500
559
|
- `f.slug({ from })` stores `from` on the descriptor, but nothing reads it:
|
|
@@ -655,9 +714,15 @@ tool — reset and reseed a development database (`supabase db reset`,
|
|
|
655
714
|
row; invites from the cloud's `/admin` (they are smoodly.io's); a
|
|
656
715
|
project whose Auth is OAuth-only (the invite mail asks for a password);
|
|
657
716
|
redirects on a published rename (the confirm says "there are none
|
|
658
|
-
yet").
|
|
717
|
+
yet").
|
|
718
|
+
- 2026-09-26: the Role column is a badge and the row menu has Change role
|
|
719
|
+
(confirm, to the other role) and Remove member; `ActionMenu` popovers are
|
|
720
|
+
fixed-positioned from the button — the cell's overflow clipped them.
|
|
659
721
|
- The members list is not paged (tens of rows, not thousands).
|
|
660
|
-
- The
|
|
722
|
+
- The Roles and API Keys pages were dropped 2026-09-25 (DESIGN.md §2
|
|
723
|
+
"Settings"): Members is the only Settings page, the invite modal
|
|
724
|
+
describes the two roles, and `/settings/roles` and `/settings/api-keys`
|
|
725
|
+
are Not found.
|
|
661
726
|
|
|
662
727
|
## Follow-ups (logged 2026-09-13, history spec)
|
|
663
728
|
|
|
@@ -812,33 +877,109 @@ tool — reset and reseed a development database (`supabase db reset`,
|
|
|
812
877
|
media projection puts the image on Media; Escape after a card's button
|
|
813
878
|
had taken the focus did not end the session.
|
|
814
879
|
|
|
815
|
-
## Follow-ups (logged 2026-09-
|
|
816
|
-
|
|
817
|
-
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
a
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
880
|
+
## Follow-ups (logged 2026-09-25, neutral restyle + confirm dialog)
|
|
881
|
+
|
|
882
|
+
- The warm look is the comparison block now (`WARM_*` + `WARM_EXTRA` in
|
|
883
|
+
`src/admin/ui/theme.tsx`, `localStorage.smoodly-look = "warm"`); the
|
|
884
|
+
technical block is deleted (tag `look-technical` keeps it). Remove the
|
|
885
|
+
warm block once the neutral look is judged, the same way.
|
|
886
|
+
- Kept from the app on purpose, against the handoff: the 14px base and
|
|
887
|
+
18px titles (handoff 13/20/22), the 64px top bar (56), the 360px
|
|
888
|
+
inspector (296), the Lucide rail icons, the `--table-top` air above the
|
|
889
|
+
list, the 12px block-tag padding. Inputs stay white on focus (the
|
|
890
|
+
handoff's focused textarea takes the tile grey) and the locked outline
|
|
891
|
+
row is a white row like the rest (the handoff's is the ground, invisible
|
|
892
|
+
on a tile of the same grey).
|
|
893
|
+
- The handoff's status chips collapse Draft into amber; the app keeps its
|
|
894
|
+
three states (Published green, Unpublished changes amber, Draft grey on
|
|
895
|
+
`--control-hover`).
|
|
896
|
+
- The Pages list's prompts are dialogs on the same card since the same
|
|
897
|
+
day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
|
|
898
|
+
path / New subpage / New subpath (title, slug following the title
|
|
899
|
+
through `slugify` in `shell/page-dialogs.ts`, page type when more than
|
|
900
|
+
one open template), Add page here (page type; skipped with one), Rename
|
|
901
|
+
(title and slug; the home page's title alone). Still native: the
|
|
902
|
+
`window.alert`s on a refused list op outside a dialog (`run` in
|
|
903
|
+
PagesList, EntriesList's add/remove) — a notice banner is the follow-up.
|
|
904
|
+
- `slugify` is the admin's first slugifier (title → segment); the slug
|
|
905
|
+
field widget (`f.slug`) does not use it yet, and neither does the entry
|
|
906
|
+
create-on-click. OQ 24 (unicode slugs) would relax its grammar.
|
|
907
|
+
- The dialog's fields take the tile tone on the white card
|
|
908
|
+
(`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
|
|
909
|
+
- The confirm's copy lives at each call site (`title` / `body` /
|
|
910
|
+
`confirmLabel` / `subject`); the shared ones are `DISCARD_CONFIRM`
|
|
911
|
+
(`ui/confirm.ts`), `LEAVE_CONFIRM` (router) and `LIVE_URL_CONFIRM`
|
|
912
|
+
(live-url). A subject path is shown where the screen already holds it;
|
|
913
|
+
the entries list and the media screen show the name only.
|
|
914
|
+
- `confirm()` without a mounted `<ConfirmHost/>` falls back to
|
|
915
|
+
`window.confirm` so nothing can hang; the host is mounted once in
|
|
916
|
+
`AdminApp` beside the shell. The tab-close guard stays native
|
|
917
|
+
(`beforeunload` cannot await).
|
|
836
918
|
- The handoff's `Styles` tab in the inspector is not built: it exists
|
|
837
919
|
only where a section registers styles, which is the existing `tabsFor`.
|
|
838
|
-
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
920
|
+
- The wordmark still takes `--logo-color` (accent); the rail's app icon
|
|
921
|
+
takes `--logo-outer` / `--logo-inner` and is hidden in the warm look.
|
|
922
|
+
- Polish round the same evening (2026-09-25): list row rules are a
|
|
923
|
+
`.sm-row::after` pseudo-element, since a border follows the rounded
|
|
924
|
+
hover corners; the admin remembers ONE locale across lists and forms
|
|
925
|
+
(`shell/locale-memory.ts`, key `smoodly-locale`; the lists' two
|
|
926
|
+
session-only keys are gone, `localeFromUrl` falls back to it and
|
|
927
|
+
remembers what it resolved); the section insert picker opens upward
|
|
928
|
+
when the room below is short and scrolls inside the room it has
|
|
929
|
+
(`placePicker` in `editor/InsertPicker.tsx`, measured in a layout
|
|
930
|
+
effect, no window-resize listener); the prefixed slug input keeps a
|
|
931
|
+
22px line box so its caret reads as one; `.sm-input--mono` is scoped
|
|
932
|
+
under `.sm-admin` — the `input{font-family:inherit}` reset had beaten
|
|
933
|
+
it, so every mono input (slugs, IDs) had rendered in the sans font;
|
|
934
|
+
richtext block-node cards take `--rtcard-bg` / `--rtcard-border` (tile
|
|
935
|
+
grey with the soft accent border in the neutral look, white and
|
|
936
|
+
hairlined in the warm). The custom section's row cards and the list
|
|
937
|
+
field's rows keep the white-on-tile look — only the richtext cards sat
|
|
938
|
+
on a white field.
|
|
939
|
+
- Richtext cards, second pass (2026-09-25): the default card border is
|
|
940
|
+
the hairline (`--rtcard-border:var(--line)`), the accent outline marks
|
|
941
|
+
the selected card alone, and the widget's focus ring steps back while a
|
|
942
|
+
block node is selected (`:focus:not(:has(.ProseMirror-selectednode))`).
|
|
943
|
+
The image card's head carries Edit / Replace / Remove as
|
|
944
|
+
`.sm-field__action` text buttons, the same three the media field now
|
|
945
|
+
shows in its field head (`MediaWidget` renders its own `Field`;
|
|
946
|
+
`FieldWidget` returns it directly, `SeoSettings` passes the label). Edit
|
|
947
|
+
on a richtext image is the crop editor over the node's `image` attr
|
|
948
|
+
(an `ImageValue`, so crop/hotspot/aspect store there). The bound
|
|
949
|
+
`smoodly/richtext` renderer applies it through `<SmoodlyImage>`; the
|
|
950
|
+
root `RichText`'s plain figure does NOT (it may not import the image
|
|
951
|
+
layer, `richtext-entry.test.ts`), so a framework-neutral renderer
|
|
952
|
+
(the Astro adapter, OQ 29) must frame it with `frameFor` itself. The
|
|
953
|
+
card shows the cropped frame (`forms/ImagePreview.tsx`, shared with the
|
|
954
|
+
media field), the filename and the caption as text; alt and caption are
|
|
955
|
+
edited in the crop modal (`CropEditor` takes `caption`), so the card
|
|
956
|
+
holds no inputs. Fields on a modal panel take the tile tone.
|
|
957
|
+
- A refused publish's per-field errors clear as the fields are fixed
|
|
958
|
+
(2026-09-25): the entry and global forms revalidate on every change with
|
|
959
|
+
the op's own rules (`validateFields` / `validateNode` over
|
|
960
|
+
`elementSchemas(registry.elements)`) and keep only what still fails
|
|
961
|
+
(`stillFailing` in `validate.ts`); the notice's list shrinks and the
|
|
962
|
+
notice goes when it empties. Typing never adds an error — only a
|
|
963
|
+
publish does. The page editor's banner list and the canvas global
|
|
964
|
+
inspector (save errors) are unchanged.
|
|
965
|
+
|
|
966
|
+
## Follow-ups (logged 2026-09-25, date pickers)
|
|
967
|
+
|
|
968
|
+
- `f.date()` and `f.datetime()` share one in-house calendar
|
|
969
|
+
(`admin/forms/DatePicker.tsx` over the pure `date-picker.ts`); the
|
|
970
|
+
browsers' own pickers were rejected (they differ and take no tokens).
|
|
971
|
+
Weeks start on Monday and month names come from the browser's language
|
|
972
|
+
— `weekStart` and a month-name locale as config keys are follow-ups.
|
|
973
|
+
- `defineConfig({ timeZone })` is validated by constructing an
|
|
974
|
+
`Intl.DateTimeFormat`, so an alias the runtime's database still knows
|
|
975
|
+
(`Europe/Kiev`) passes and an offset never does. Admin-owned site
|
|
976
|
+
settings (a per-space `settings` table with a Site page) were designed
|
|
977
|
+
and dropped for the zone; the design is in the spec's history should a
|
|
978
|
+
setting turn up that editors must change without a deploy.
|
|
979
|
+
- Not built: a time-only field, a date range, `.min` / `.max` on dates.
|
|
980
|
+
- Publish refuses a malformed date or datetime now (`validate.ts`); a
|
|
981
|
+
row saved before this with a bad string shows the message on its next
|
|
982
|
+
publish, as designed.
|
|
842
983
|
|
|
843
984
|
## Follow-ups (logged 2026-09-08, block nodes spec)
|
|
844
985
|
|
|
@@ -1031,7 +1172,8 @@ the toolbar instead of pushing the content.
|
|
|
1031
1172
|
- ~~Drag-to-resize the inspector~~ — DONE 2026-09-10 (spec
|
|
1032
1173
|
`docs/superpowers/specs/2026-09-10-inspector-resize-design.md`):
|
|
1033
1174
|
`ui/ResizeHandle.tsx` writes `--inspector-w` inline on the wrapper,
|
|
1034
|
-
clamped to 260px..
|
|
1175
|
+
clamped to 260px..the row less the handle and a 440px canvas (`ui/resize.ts`,
|
|
1176
|
+
half the row until 2026-09-26), remembered under
|
|
1035
1177
|
`smoodly-inspector-w` and applied by the theme's boot script. Walk
|
|
1036
1178
|
finding: the desktop pane hides its tab on every `navigate`, and a
|
|
1037
1179
|
hidden tab lays out at zero width, so the handle treats a zero
|
|
@@ -1363,10 +1505,10 @@ the toolbar instead of pushing the content.
|
|
|
1363
1505
|
`page:<id>:<locale>` tag would halve the revalidations on a bilingual
|
|
1364
1506
|
site.
|
|
1365
1507
|
- The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
|
|
1366
|
-
`Delete
|
|
1367
|
-
reports a refused add or remove with
|
|
1368
|
-
|
|
1369
|
-
|
|
1508
|
+
`Delete …`) are the design system's dialog since 2026-09-25;
|
|
1509
|
+
`EntriesList` still reports a refused add or remove with
|
|
1510
|
+
`window.alert` — the follow-up, together with the row menu extraction
|
|
1511
|
+
already logged above.
|
|
1370
1512
|
- `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
|
|
1371
1513
|
add and remove flows — see the 2026-09-03 follow-up below for the seams
|
|
1372
1514
|
(that entry is the one to act on).
|
|
@@ -1479,13 +1621,13 @@ the toolbar instead of pushing the content.
|
|
|
1479
1621
|
those values in `entry_locales.fields` until the next save of each
|
|
1480
1622
|
locale (a save writes only the current split) — the drift/codemod
|
|
1481
1623
|
pipeline (Phase 2) is where "keep en, discard fi" belongs.
|
|
1482
|
-
-
|
|
1483
|
-
is
|
|
1484
|
-
|
|
1485
|
-
`examples/site/smoodly/collections.ts
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1624
|
+
- Resolved 2026-09-26 (schema migrations spec): the repo-root
|
|
1625
|
+
`supabase/migrations/` is the package's core files plus a views file
|
|
1626
|
+
that `npm run db:migrate` writes from
|
|
1627
|
+
`examples/site/smoodly/collections.ts` — the same source the
|
|
1628
|
+
create-smoodly-app templates use; `scripts/emit-sql.ts` and its own
|
|
1629
|
+
fixture collections are gone (`scripts/emit-migration.ts` drafts the
|
|
1630
|
+
next core file).
|
|
1489
1631
|
- In the entry form, a shared ref field (e.g. an article's author) shows
|
|
1490
1632
|
`—` in a locale where the target is absent, because the picker offers
|
|
1491
1633
|
only targets present in the edited locale; the value is kept on the
|
|
@@ -1685,5 +1827,5 @@ the toolbar instead of pushing the content.
|
|
|
1685
1827
|
- Editor canvas URL mapping: now `homePageSlug` config (done 2026-09-03); remaining follow-up is a registration-declared `path` for fixed pages at arbitrary paths.
|
|
1686
1828
|
- `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
|
|
1687
1829
|
- Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
|
|
1688
|
-
- The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics.
|
|
1830
|
+
- The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics. — Partly resolved 2026-09-26: the `browser` job builds the example site and drives it, but with a database; the no-database build is still unscripted.
|
|
1689
1831
|
- `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). The ref-option loading became `forms/useRefOptions.ts` (2026-09-17). (logged 2026-09-06, updated 2026-09-08)
|
package/bin/smoodly.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx smoodly …` — resolves to the app's installed package, so the files
|
|
3
|
+
// it copies belong to the version the app runs (spec 2026-09-26 §2).
|
|
4
|
+
import { main } from "../dist/cli/main.js";
|
|
5
|
+
|
|
6
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -4,6 +4,7 @@ const GROUPS = {
|
|
|
4
4
|
globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
|
|
5
5
|
assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
|
|
6
6
|
members: ["list", "me", "invite", "setRole", "remove"],
|
|
7
|
+
schema: ["status"],
|
|
7
8
|
preview: ["enable", "disable"],
|
|
8
9
|
};
|
|
9
10
|
export function makeClientOps(action, token) {
|
|
@@ -27,6 +27,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
27
27
|
//
|
|
28
28
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
29
29
|
// that knows about Next, and it ships to the site, not the admin.
|
|
30
|
+
import { Eye } from "lucide-react";
|
|
30
31
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
32
|
import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
|
|
32
33
|
import { descriptorAt, getAtPath } from "../field-paths.js";
|
|
@@ -57,6 +58,7 @@ import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree
|
|
|
57
58
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
58
59
|
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
59
60
|
import { PublishControl } from "../ui/PublishControl.js";
|
|
61
|
+
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
60
62
|
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
61
63
|
import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
|
|
62
64
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
@@ -107,7 +109,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
107
109
|
registryRef.current = registry;
|
|
108
110
|
// The tree in memory belongs to the locale it was loaded for; a switch
|
|
109
111
|
// flushes first (switchLocale), so the ref and the tree always agree.
|
|
110
|
-
|
|
112
|
+
// (Read once: localeFromUrl also remembers the locale.)
|
|
113
|
+
const [openedLocale] = useState(() => localeFromUrl(registry));
|
|
114
|
+
const treeLocaleRef = useRef(openedLocale);
|
|
111
115
|
// Every edit marks the draft dirty; a debounced saveDraft persists, with
|
|
112
116
|
// retry on failure, and the bridge refreshes the iframe on the save ack —
|
|
113
117
|
// now, or once an inline session ends (spec 2026-09-10 §7).
|
|
@@ -499,12 +503,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
499
503
|
mutate((t) => insertNode(t, zone, index, copy));
|
|
500
504
|
select(copy.id);
|
|
501
505
|
};
|
|
502
|
-
const doRemove = (id,
|
|
506
|
+
const doRemove = async (id, ask = true) => {
|
|
503
507
|
if (!tree)
|
|
504
508
|
return;
|
|
505
509
|
if (policyOf(zoneOf(tree, id))?.kind === "locked")
|
|
506
510
|
return;
|
|
507
|
-
|
|
511
|
+
const node = findNode(tree, id);
|
|
512
|
+
if (ask && !(await confirm({
|
|
513
|
+
title: "Delete this section?",
|
|
514
|
+
body: "It is removed from the page draft. Nothing changes on the live site until you publish.",
|
|
515
|
+
confirmLabel: "Delete section",
|
|
516
|
+
destructive: true,
|
|
517
|
+
subject: node ? { name: labelOf(node) } : undefined,
|
|
518
|
+
})))
|
|
508
519
|
return;
|
|
509
520
|
mutate((t) => removeNode(t, id));
|
|
510
521
|
setSelectedId((current) => (current === id ? null : current));
|
|
@@ -550,7 +561,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
550
561
|
};
|
|
551
562
|
/** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload the canvas. */
|
|
552
563
|
const doDiscard = async () => {
|
|
553
|
-
|
|
564
|
+
const row = record?.locales[locale];
|
|
565
|
+
if (!(await confirm({ ...DISCARD_CONFIRM, subject: row ? { name: row.title, path } : undefined })))
|
|
554
566
|
return;
|
|
555
567
|
setBanner(null);
|
|
556
568
|
setPageBusy("discard");
|
|
@@ -650,7 +662,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
650
662
|
setBanner({ text: r.message, fields: r.fields });
|
|
651
663
|
};
|
|
652
664
|
const doDiscardGlobal = async (name) => {
|
|
653
|
-
|
|
665
|
+
const item = registry.globals.find((g) => g.name === name);
|
|
666
|
+
if (!(await confirm({ ...DISCARD_CONFIRM, subject: item ? { name: `${item.title} · global` } : undefined })))
|
|
654
667
|
return;
|
|
655
668
|
setBanner(null);
|
|
656
669
|
setGlobalBusy("discard");
|
|
@@ -752,7 +765,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
752
765
|
trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
|
|
753
766
|
},
|
|
754
767
|
{ label },
|
|
755
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "
|
|
768
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
769
|
+
setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
756
770
|
const { session } = inline.richtext;
|
|
757
771
|
const item = globalItemOf(session.node);
|
|
758
772
|
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import type { ZonePolicy } from "../../zones.ts";
|
|
2
2
|
import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
|
|
3
|
+
/** Where the "+" put the panel: `y` is 12px under the button's centre (EditorView). */
|
|
3
4
|
export type PickerAt = {
|
|
4
5
|
zone: string;
|
|
5
6
|
index: number;
|
|
6
7
|
x: number;
|
|
7
8
|
y: number;
|
|
8
9
|
};
|
|
10
|
+
export type PickerPlace = {
|
|
11
|
+
left: number;
|
|
12
|
+
top?: number;
|
|
13
|
+
bottom?: number;
|
|
14
|
+
maxHeight?: number;
|
|
15
|
+
};
|
|
16
|
+
/** Where the panel sits, given its natural height and the viewport: under the
|
|
17
|
+
* anchor when it fits there, above it when that side has more room, and never
|
|
18
|
+
* taller than the room on its side (the panel scrolls). Pure. */
|
|
19
|
+
export declare function placePicker(at: PickerAt, height: number, viewport: {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}): PickerPlace;
|
|
9
23
|
export declare function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
|
|
10
24
|
at: PickerAt;
|
|
11
25
|
policy: ZonePolicy | undefined;
|
|
@@ -1,20 +1,50 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The insert picker a "+" opens: the sections the zone's policy allows,
|
|
4
|
+
// then the Custom section where the zone allows it (spec 2026-09-14 §6),
|
|
5
|
+
// then the global items that can be placed there, or a note that the
|
|
6
|
+
// zone takes nothing. Clicking outside closes it. The panel hangs under
|
|
7
|
+
// the "+" when it fits, opens upward when there is more room above, and
|
|
8
|
+
// scrolls inside the room it has (2026-09-25) — a long section list near
|
|
9
|
+
// the bottom of the window used to run off screen.
|
|
10
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
11
|
import { allowedSections, allowedGlobals } from "../tree-ops.js";
|
|
4
12
|
import { allowsCustom } from "../custom-section.js";
|
|
5
13
|
import { SectionLabel } from "../ui/primitives.js";
|
|
6
|
-
|
|
7
|
-
|
|
14
|
+
const PICKER_W = 300;
|
|
15
|
+
const MARGIN = 12;
|
|
16
|
+
/** From `y` back up to the panel's bottom edge when it opens upward: the 12px the
|
|
17
|
+
* anchor sits under the button's centre, and the same 12px above it. */
|
|
18
|
+
const ABOVE_GAP = 24;
|
|
19
|
+
/** Where the panel sits, given its natural height and the viewport: under the
|
|
20
|
+
* anchor when it fits there, above it when that side has more room, and never
|
|
21
|
+
* taller than the room on its side (the panel scrolls). Pure. */
|
|
22
|
+
export function placePicker(at, height, viewport) {
|
|
23
|
+
const left = Math.max(MARGIN, Math.min(at.x, viewport.width - PICKER_W - MARGIN));
|
|
24
|
+
const below = viewport.height - MARGIN - at.y;
|
|
25
|
+
const above = at.y - ABOVE_GAP - MARGIN;
|
|
26
|
+
if (height <= below || below >= above)
|
|
27
|
+
return { left, top: at.y, maxHeight: Math.max(below, 0) };
|
|
28
|
+
return { left, bottom: viewport.height - (at.y - ABOVE_GAP), maxHeight: above };
|
|
29
|
+
}
|
|
30
|
+
/** The first paint, before the panel is measured; a static render (no window) leaves it where it was asked. */
|
|
31
|
+
function firstPlace(at) {
|
|
8
32
|
if (typeof window === "undefined")
|
|
9
33
|
return { left: at.x, top: at.y };
|
|
10
|
-
return {
|
|
11
|
-
left: Math.max(12, Math.min(at.x, window.innerWidth - 320)),
|
|
12
|
-
top: Math.min(at.y, Math.max(12, window.innerHeight - 240)),
|
|
13
|
-
};
|
|
34
|
+
return placePicker(at, 0, { width: window.innerWidth, height: window.innerHeight });
|
|
14
35
|
}
|
|
15
36
|
export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
|
|
16
37
|
const sections = allowedSections(policy, registry.sections);
|
|
17
38
|
const globals = allowedGlobals(policy, registry.globals, globalIds);
|
|
18
39
|
const custom = allowsCustom(policy);
|
|
19
|
-
|
|
40
|
+
const panel = useRef(null);
|
|
41
|
+
const [place, setPlace] = useState(() => firstPlace(at));
|
|
42
|
+
// Measure the panel's full height (scrollHeight ignores a max-height already set), then place it.
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
const el = panel.current;
|
|
45
|
+
if (!el)
|
|
46
|
+
return;
|
|
47
|
+
setPlace(placePicker(at, el.scrollHeight + 2, { width: window.innerWidth, height: window.innerHeight }));
|
|
48
|
+
}, [at]);
|
|
49
|
+
return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Site" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
|
|
20
50
|
}
|
|
@@ -17,6 +17,6 @@ export function InspectorFields({ groups, values, render }) {
|
|
|
17
17
|
const entries = visibleEntries(g.entries, values);
|
|
18
18
|
if (entries.length === 0)
|
|
19
19
|
return null;
|
|
20
|
-
return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, {
|
|
20
|
+
return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
|
|
21
21
|
}) }));
|
|
22
22
|
}
|
|
@@ -10,6 +10,6 @@ function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAdd
|
|
|
10
10
|
count: locked ? 0 : nodes.length,
|
|
11
11
|
onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
|
|
12
12
|
});
|
|
13
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, {
|
|
14
|
-
onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
|
|
13
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
|
|
14
|
+
onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
|
|
15
15
|
}
|
|
@@ -32,7 +32,7 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
// A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
|
|
35
|
-
if (key === "slug" && !confirmLiveUrl({ status: row.status, from: row.slug, to: next })) {
|
|
35
|
+
if (key === "slug" && !(await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }))) {
|
|
36
36
|
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
@@ -11,5 +11,5 @@ function Counter({ n, max }) {
|
|
|
11
11
|
export function SeoSettings({ meta, onPatch }) {
|
|
12
12
|
const title = str(meta.title);
|
|
13
13
|
const description = str(meta.description);
|
|
14
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(
|
|
14
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v), label: "Social image", hint: "1200\u00D7630 recommended" }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
|
|
15
15
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { AdminRegistry } from "../serialize.ts";
|
|
2
2
|
type Locales = AdminRegistry["locales"];
|
|
3
|
-
/** A supported ?locale from the query string, else the default. */
|
|
4
|
-
export declare function localeFromSearch(search: string, locales: Locales): string;
|
|
3
|
+
/** A supported ?locale from the query string, else the fallback (the default). */
|
|
4
|
+
export declare function localeFromSearch(search: string, locales: Locales, fallback?: string): string;
|
|
5
5
|
/** The same URL pointed at a locale, keeping the rest of the query
|
|
6
6
|
* string — a bare `?locale=…` would drop every other param. */
|
|
7
7
|
export declare function withLocale(href: string, next: string): string;
|
|
8
|
+
/** The URL's locale, else the remembered one, else the default — and
|
|
9
|
+
* remembered from here on. Read once per screen, not per render. */
|
|
8
10
|
export declare function localeFromUrl(registry: AdminRegistry): string;
|
|
9
|
-
/** Point the current URL at a locale in place. A replace,
|
|
10
|
-
* never asks the leave guard — the form's own confirm
|
|
11
|
+
/** Point the current URL at a locale in place, remembering it. A replace,
|
|
12
|
+
* so the router never asks the leave guard — the form's own confirm
|
|
13
|
+
* covers a switch. */
|
|
11
14
|
export declare function writeLocaleToUrl(next: string): void;
|
|
12
15
|
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { navigate } from "../shell/router.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { rememberLocale, rememberedLocale } from "../shell/locale-memory.js";
|
|
3
|
+
/** A supported ?locale from the query string, else the fallback (the default). */
|
|
4
|
+
export function localeFromSearch(search, locales, fallback = locales.default) {
|
|
4
5
|
const l = new URLSearchParams(search).get("locale");
|
|
5
|
-
return l && locales.supported.includes(l) ? l :
|
|
6
|
+
return l && locales.supported.includes(l) ? l : fallback;
|
|
6
7
|
}
|
|
7
8
|
/** The same URL pointed at a locale, keeping the rest of the query
|
|
8
9
|
* string — a bare `?locale=…` would drop every other param. */
|
|
@@ -11,20 +12,26 @@ export function withLocale(href, next) {
|
|
|
11
12
|
u.searchParams.set("locale", next);
|
|
12
13
|
return u.toString();
|
|
13
14
|
}
|
|
15
|
+
/** The URL's locale, else the remembered one, else the default — and
|
|
16
|
+
* remembered from here on. Read once per screen, not per render. */
|
|
14
17
|
export function localeFromUrl(registry) {
|
|
15
18
|
try {
|
|
16
|
-
|
|
19
|
+
const locale = localeFromSearch(window.location.search, registry.locales, rememberedLocale(registry.locales));
|
|
20
|
+
rememberLocale(locale);
|
|
21
|
+
return locale;
|
|
17
22
|
}
|
|
18
23
|
catch {
|
|
19
24
|
// no window — the default is fine
|
|
20
25
|
return registry.locales.default;
|
|
21
26
|
}
|
|
22
27
|
}
|
|
23
|
-
/** Point the current URL at a locale in place. A replace,
|
|
24
|
-
* never asks the leave guard — the form's own confirm
|
|
28
|
+
/** Point the current URL at a locale in place, remembering it. A replace,
|
|
29
|
+
* so the router never asks the leave guard — the form's own confirm
|
|
30
|
+
* covers a switch. */
|
|
25
31
|
export function writeLocaleToUrl(next) {
|
|
32
|
+
rememberLocale(next);
|
|
26
33
|
try {
|
|
27
|
-
navigate(withLocale(window.location.href, next), { replace: true });
|
|
34
|
+
void navigate(withLocale(window.location.href, next), { replace: true });
|
|
28
35
|
}
|
|
29
36
|
catch {
|
|
30
37
|
// no window (or no history) — the URL is cosmetic here
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { useState } from "react";
|
|
6
6
|
import { sourceLocaleOf } from "../shell/pages-tree.js";
|
|
7
7
|
import { localeFromUrl, writeLocaleToUrl } from "./locale-url.js";
|
|
8
|
+
import { confirm } from "../ui/confirm.js";
|
|
8
9
|
export function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }) {
|
|
9
10
|
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
10
11
|
const [adding, setAdding] = useState(false);
|
|
@@ -59,7 +60,14 @@ export function usePageLocale({ registry, ops, id, record, setRecord, flush, fai
|
|
|
59
60
|
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
60
61
|
if (remaining.length === 0)
|
|
61
62
|
return;
|
|
62
|
-
|
|
63
|
+
const ok = await confirm({
|
|
64
|
+
title: `Remove this page from ${locale}?`,
|
|
65
|
+
body: `Its ${locale} content and history are deleted. Other languages are not affected.`,
|
|
66
|
+
confirmLabel: `Remove from ${locale}`,
|
|
67
|
+
destructive: true,
|
|
68
|
+
subject: { name: record.locales[locale]?.title ?? id },
|
|
69
|
+
});
|
|
70
|
+
if (!ok)
|
|
63
71
|
return;
|
|
64
72
|
// Let a pending save land before the row goes, so no save races the
|
|
65
73
|
// delete — and if it cannot land, stop: a retry mid-flight would
|