From 87f5094ca58ecff6a1cb5ac38111cde231b30737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 06:02:45 +0200 Subject: [PATCH 1/3] The logo above the auth cards leads nowhere, and a phone can add a place (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the four things on #66, the two that need no schema. The logo sat above every auth card as a link to `/`. A visitor part-way through making an account was being offered a way out of the view, which is a conversion question before it is a styling one (Dawid, 12.09.2026). It was also broken as navigation: `/` sends a signed-in visitor onward and one who has not finished setting up straight back, so after verification the link looped to the page it was on. `href` on Logo/LogoMark is now optional and no href means no anchor — a span, which a keyboard and a screen reader both pass by. All seven auth screens drop it; the homepage, the public profile, settings and the owner's view keep theirs. Nobody is stranded: every one of those screens carries its own links. Adding a place on a phone was impossible. Only Enter committed, and a phone's keyboard offers "next" — which is Tab, so the key moved focus and took the typed text with it. Tab now adds what Enter would and still moves on; on a desktop it only rescues text that tabbing away was about to discard anyway. The field gets enterKeyHint="done" so the phone's key says what it does (it is not inside a form, so there was nothing to infer it from), and the hint under the field names both keys in both languages. Blur still discards, on purpose. Added while there: the place list says out loud what happened to it. A chip appearing was the whole of the feedback, and the field empties itself the moment a place is taken — to a screen reader, indistinguishable from the text being thrown away. Verified in a browser: on /register the wordmark is a span with the screen's own "Log in" link intact, and on the homepage the logo is still a link. Co-Authored-By: Claude Opus 5 --- messages/en.json | 4 ++- messages/pl.json | 4 ++- .../[locale]/(auth)/email-changed/page.tsx | 2 +- src/app/[locale]/(auth)/login/page.tsx | 2 +- src/app/[locale]/(auth)/register/page.tsx | 2 +- .../(auth)/register/verified/page.tsx | 2 +- .../(auth)/reset-password/new/page.tsx | 2 +- .../[locale]/(auth)/reset-password/page.tsx | 2 +- src/app/[locale]/(auth)/two-factor/page.tsx | 2 +- .../(public)/[handle]/owner-profile-view.tsx | 23 ++++++++++++ src/components/ui/logo-mark.tsx | 35 +++++++++++++------ src/components/ui/logo.tsx | 14 ++++---- 12 files changed, 69 insertions(+), 25 deletions(-) diff --git a/messages/en.json b/messages/en.json index 440005c..ab1fee0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -231,8 +231,10 @@ "locations": { "label": "Add a place", "placeholder": "City, voivodeship or your own words", - "hint": "Suggestions come from the TERYT register. Enter adds your own text, e.g. “all of Poland” or “Berlin”.", + "hint": "Suggestions come from the TERYT register. Enter — or Tab on a phone keyboard — adds your own text, e.g. “all of Poland” or “Berlin”.", "remove": "Remove {place}", + "added": "Place added: {place}", + "removed": "Place removed: {place}", "suggestions": "Suggestions", "kind": { "voivodeship": "voivodeship", diff --git a/messages/pl.json b/messages/pl.json index a2b358a..08ae55b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -231,8 +231,10 @@ "locations": { "label": "Dodaj miejsce", "placeholder": "Miasto, województwo albo własny opis", - "hint": "Podpowiedzi z rejestru TERYT: województwa, powiaty, gminy i wszystkie miejscowości. Enter dodaje własny tekst, np. „cała Polska” albo „Berlin”.", + "hint": "Podpowiedzi z rejestru TERYT: województwa, powiaty, gminy i wszystkie miejscowości. Enter — albo Tab na klawiaturze telefonu — dodaje własny tekst, np. „cała Polska” albo „Berlin”.", "remove": "Usuń {place}", + "added": "Dodano miejsce: {place}", + "removed": "Usunięto miejsce: {place}", "suggestions": "Podpowiedzi", "kind": { "voivodeship": "województwo", diff --git a/src/app/[locale]/(auth)/email-changed/page.tsx b/src/app/[locale]/(auth)/email-changed/page.tsx index a40bcd7..a54a908 100644 --- a/src/app/[locale]/(auth)/email-changed/page.tsx +++ b/src/app/[locale]/(auth)/email-changed/page.tsx @@ -45,7 +45,7 @@ export default async function EmailChangedPage({ return (
- +

{t(`${state}Heading`)}

diff --git a/src/app/[locale]/(auth)/login/page.tsx b/src/app/[locale]/(auth)/login/page.tsx index 7feaebb..5320377 100644 --- a/src/app/[locale]/(auth)/login/page.tsx +++ b/src/app/[locale]/(auth)/login/page.tsx @@ -28,7 +28,7 @@ export default async function LoginPage({ return (

- +

{t("heading")}

diff --git a/src/app/[locale]/(auth)/register/page.tsx b/src/app/[locale]/(auth)/register/page.tsx index 98987d8..9e3494e 100644 --- a/src/app/[locale]/(auth)/register/page.tsx +++ b/src/app/[locale]/(auth)/register/page.tsx @@ -28,7 +28,7 @@ export default async function RegisterPage({ return (
- +

{t("heading")}

diff --git a/src/app/[locale]/(auth)/register/verified/page.tsx b/src/app/[locale]/(auth)/register/verified/page.tsx index 9401bd2..06f32b8 100644 --- a/src/app/[locale]/(auth)/register/verified/page.tsx +++ b/src/app/[locale]/(auth)/register/verified/page.tsx @@ -45,7 +45,7 @@ export default async function VerifiedPage({ return (
- + {state === "success" ? ( <> diff --git a/src/app/[locale]/(auth)/reset-password/new/page.tsx b/src/app/[locale]/(auth)/reset-password/new/page.tsx index 0e4786d..876f414 100644 --- a/src/app/[locale]/(auth)/reset-password/new/page.tsx +++ b/src/app/[locale]/(auth)/reset-password/new/page.tsx @@ -35,7 +35,7 @@ export default async function NewPasswordPage({ return (
- + {token ? ( <> diff --git a/src/app/[locale]/(auth)/reset-password/page.tsx b/src/app/[locale]/(auth)/reset-password/page.tsx index 7862ed2..88ddc92 100644 --- a/src/app/[locale]/(auth)/reset-password/page.tsx +++ b/src/app/[locale]/(auth)/reset-password/page.tsx @@ -28,7 +28,7 @@ export default async function ResetPasswordPage({ return (
- +

{t("heading")}

diff --git a/src/app/[locale]/(auth)/two-factor/page.tsx b/src/app/[locale]/(auth)/two-factor/page.tsx index a0d079e..6561bdd 100644 --- a/src/app/[locale]/(auth)/two-factor/page.tsx +++ b/src/app/[locale]/(auth)/two-factor/page.tsx @@ -34,7 +34,7 @@ export default async function TwoFactorPage({ return (
- +

{t("heading")}

diff --git a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx index 7e71d57..d173431 100644 --- a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx +++ b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx @@ -157,6 +157,8 @@ export function OwnerProfileView({ const [headlineError, setHeadlineError] = useState(null); const [bioError, setBioError] = useState(null); const [locationsError, setLocationsError] = useState(null); + /** What a screen reader is told about the place list, out loud (#66). */ + const [locationsNotice, setLocationsNotice] = useState(""); const [avatarBusy, setAvatarBusy] = useState(false); // #80: the bytes on their way, 0..1, then 1 while the server processes; @@ -291,10 +293,15 @@ export function OwnerProfileView({ setLocationsError(tSections("locations.tooMany", { max: LOCATIONS_MAX })); return; } + // #66: a chip appearing is the whole of the feedback, and a chip is not + // announced. The field empties itself the moment a place is taken, which + // to a screen reader is indistinguishable from the text being thrown away. + setLocationsNotice(tSections("locations.added", { place })); void saveLocations([...fields.locations, place]); } function removePlace(place: string) { + setLocationsNotice(tSections("locations.removed", { place })); void saveLocations( fields.locations.filter((existing) => existing !== place), ); @@ -724,6 +731,9 @@ export function OwnerProfileView({ onAdd={addPlace} error={locationsError} /> +

+ {locationsNotice} +

) : ( @@ -1041,6 +1051,10 @@ function PlaceCombobox({ placeholder={tSections("locations.placeholder")} autoComplete="off" maxLength={LOCATION_MAX} + // The field is not inside a form, so a phone has nothing to infer + // the key's job from and shows a bare return. "done" makes it say + // so — the hint under the field names both keys (#66). + enterKeyHint="done" role="combobox" aria-expanded={hits.length > 0} aria-controls={listId} @@ -1073,6 +1087,15 @@ function PlaceCombobox({ event.preventDefault(); const chosen = activeIndex >= 0 ? hits[activeIndex] : undefined; choose(chosen ? chosen.name : query); + } else if (event.key === "Tab" && query.trim()) { + // #66: a phone's keyboard offers "next" where a desktop offers + // Enter, and next is Tab — so a place typed on a phone could not + // be added at all: the key moved focus and the text went with + // it. Tab adds what Enter would and then goes on its way (no + // preventDefault), which on a desktop only rescues text that + // tabbing away was about to discard anyway. + const chosen = activeIndex >= 0 ? hits[activeIndex] : undefined; + choose(chosen ? chosen.name : query); } else if (event.key === "Escape") { setOpen(false); setActiveIndex(-1); diff --git a/src/components/ui/logo-mark.tsx b/src/components/ui/logo-mark.tsx index 135029e..4882e8c 100644 --- a/src/components/ui/logo-mark.tsx +++ b/src/components/ui/logo-mark.tsx @@ -3,7 +3,13 @@ import { Link } from "@/i18n/navigation"; import { Mark } from "./mark"; type LogoMarkProps = { - href: ComponentPropsWithoutRef["href"]; + /** + * Left out where the logo must not lead anywhere: above the auth cards, a + * visitor part-way through making an account is not offered a way out of + * the view (#66). No href, no anchor — rather than a link and a second + * prop that could contradict it. + */ + href?: ComponentPropsWithoutRef["href"]; wordmark: string; onPhoto?: boolean; /** "compact" is the smaller mark+type-h4 pairing above the auth cards @@ -21,19 +27,28 @@ export function LogoMark({ href, wordmark, onPhoto = false, size = "default" }: const text = onPhoto ? "text-(--text-on-photo)" : "text-(--text-strong)"; const markSize = size === "compact" ? 26 : 30; const wordmarkClass = size === "compact" ? "type-h4" : "type-h3"; + // whitespace-nowrap: "Architektów 3d" broke across two lines inside the top + // bar on a phone, which stretched the bar and shoved the actions off screen. + // It fits without wrapping at every width we support — 30px mark + 8px gap + + // ~123px of wordmark is 161px of the 328px a 360px screen leaves between the + // gutters — so the mark and type keep their one size rather than gaining a + // second, smaller pairing to maintain. + const row = "flex items-center gap-(--sp-3) whitespace-nowrap sm:gap-(--sp-4)"; + const mark = ( + <> + + {wordmark} + + ); + // Not a link, not focusable, and nothing to announce beyond the wordmark + // itself — a span, so a keyboard and a screen reader both pass it by. + if (href === undefined) return {mark}; return ( - // whitespace-nowrap: "Architektów 3d" broke across two lines inside the - // top bar on a phone, which stretched the bar and shoved the actions off - // screen. It fits without wrapping at every width we support — 30px mark - // + 8px gap + ~123px of wordmark is 161px of the 328px a 360px screen - // leaves between the gutters — so the mark and type keep their one size - // rather than gaining a second, smaller pairing to maintain. - - {wordmark} + {mark} ); } diff --git a/src/components/ui/logo.tsx b/src/components/ui/logo.tsx index f28ec8a..f478fbe 100644 --- a/src/components/ui/logo.tsx +++ b/src/components/ui/logo.tsx @@ -4,16 +4,18 @@ import { Link } from "@/i18n/navigation"; import { LogoMark } from "./logo-mark"; type LogoProps = { - href: ComponentPropsWithoutRef["href"]; + /** Omitted above the auth cards, where it must lead nowhere (#66). */ + href?: ComponentPropsWithoutRef["href"]; onPhoto?: boolean; size?: "default" | "compact"; }; -// Mark + wordmark, clickable everywhere: to the hero for a signed-out -// visitor, to the visitor's own public profile once signed in (the hero is -// unreachable once logged in) — callers pass the right href for the page -// they're on. A server component (like every page that renders it) so it -// can use getTranslations directly, matching this codebase's convention. +// Mark + wordmark: to the hero for a signed-out visitor, to the visitor's own +// public profile once signed in (the hero is unreachable once logged in) — +// callers pass the right href for the page they're on, or none at all where +// the logo is branding rather than a way out (#66). A server component (like +// every page that renders it) so it can use getTranslations directly, +// matching this codebase's convention. export async function Logo({ href, onPhoto = false, size = "default" }: LogoProps) { const t = await getTranslations("Brand"); const wordmark = t("wordmark"); From 338888be5dc199470aebe5c472cc12da69aab61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 06:37:32 +0200 Subject: [PATCH 2/3] Two lists the owner can put in order, by hand and by keyboard (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The places on a profile and the works below it both had one order: the one they were added in. Now the owner drags either where they want it, and a visitor reads it that way. One piece of machinery for both, because they look nothing alike — a wrapped row of chips and a grid of cards. lib/reorder.ts holds the arithmetic (move an item; which box a pointer is over, falling back to the nearest middle so a finger in the gap between two cards still means something) and components/ui/use-reorder.ts the hand on it. Pointer events rather than HTML5 drag-and-drop: dragstart never fires on a touch screen, and half of this is used on a phone. The same grip answers the arrow keys and keeps focus on the item it moved, so the feature exists for a keyboard too. Places cost nothing to order: the order IS the array, saved by the call that already saves them. Works needed a column — position, backfilled from created_at so nothing shuffled the day it arrived, with created_at still breaking the tie. The whole order goes to /api/works/order, which refuses one about any list other than the owner's current works: a second tab adding or deleting a work makes the save fail rather than quietly move something nobody touched. A refused save puts the order on screen back. The schema canaries did their job and were extended consciously: works grew a column, and the works index was re-created with the owner's order in front of the adding order it used to carry alone. Verified against a real browser and the test database: grips only while editing, arrows moving a work with the save asserted and surviving a reload, a drag by the grip landing where it was dropped, a visitor seeing the owner's order, and the same for places. The drag test caught two real faults in itself — a reload racing the save, and a card dragged at coordinates below the fold. Co-Authored-By: Claude Opus 5 --- drizzle/0019_works_position.sql | 14 + drizzle/meta/0019_snapshot.json | 1571 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- e2e/db/profile-sections.spec.ts | 48 + e2e/db/seed-works.ts | 16 +- e2e/db/works-order.spec.ts | 141 ++ messages/en.json | 6 + messages/pl.json | 6 + .../(public)/[handle]/owner-profile-view.tsx | 100 +- .../(public)/[handle]/profile-sections.tsx | 45 +- .../(public)/[handle]/works-gallery.tsx | 69 +- src/app/api/works/order/route.ts | 42 + src/components/ui/icon.tsx | 14 +- src/components/ui/use-reorder.ts | 156 ++ src/db/schema.test.ts | 5 + src/db/schema.ts | 19 +- src/lib/reorder.test.ts | 74 + src/lib/reorder.ts | 74 + src/lib/work-schemas.ts | 19 + src/lib/works.ts | 59 +- 20 files changed, 2455 insertions(+), 32 deletions(-) create mode 100644 drizzle/0019_works_position.sql create mode 100644 drizzle/meta/0019_snapshot.json create mode 100644 e2e/db/works-order.spec.ts create mode 100644 src/app/api/works/order/route.ts create mode 100644 src/components/ui/use-reorder.ts create mode 100644 src/lib/reorder.test.ts create mode 100644 src/lib/reorder.ts diff --git a/drizzle/0019_works_position.sql b/drizzle/0019_works_position.sql new file mode 100644 index 0000000..a86794e --- /dev/null +++ b/drizzle/0019_works_position.sql @@ -0,0 +1,14 @@ +DROP INDEX "works_user_id_created_at_idx";--> statement-breakpoint +ALTER TABLE "works" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +-- #66: existing works keep the order they already had. The default of 0 would +-- do that on its own, since created_at still breaks the tie — this makes the +-- column say it rather than leave every row claiming to be first. +UPDATE "works" AS w +SET "position" = ordered.rank +FROM ( + SELECT "id", + row_number() OVER (PARTITION BY "user_id" ORDER BY "created_at", "id") - 1 AS rank + FROM "works" +) AS ordered +WHERE w."id" = ordered."id";--> statement-breakpoint +CREATE INDEX "works_user_id_position_idx" ON "works" USING btree ("user_id","position","created_at"); diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..9177650 --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1571 @@ +{ + "id": "18c9c605-46e8-44d5-ab1b-c06c36d1e020", + "prevId": "7bc7dc86-9bd4-41cf-92c7-1f5768189222", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_issuer_account_id_unique": { + "name": "accounts_issuer_account_id_unique", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "file_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_file_id": { + "name": "parent_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_user_id_size_bytes_idx": { + "name": "files_user_id_size_bytes_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_bytes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_original_user_sha256_unique": { + "name": "files_original_user_sha256_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"parent_file_id\" IS NULL AND (\"files\".\"object_key\" IS NULL OR \"files\".\"object_key\" NOT LIKE '%/r360/%')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_variant_user_parent_kind_unique": { + "name": "files_variant_user_parent_kind_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"parent_file_id\" IS NOT NULL AND (\"files\".\"object_key\" IS NULL OR \"files\".\"object_key\" NOT LIKE '%/r360/%')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_r360_frame_user_object_key_unique": { + "name": "files_r360_frame_user_object_key_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"object_key\" LIKE '%/r360/%'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_parent_file_id_idx": { + "name": "files_parent_file_id_idx", + "columns": [ + { + "expression": "parent_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_object_key_idx": { + "name": "files_object_key_idx", + "columns": [ + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_parent_file_id_files_id_fk": { + "name": "files_parent_file_id_files_id_fk", + "tableFrom": "files", + "tableTo": "files", + "columnsFrom": [ + "parent_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handle_redirects": { + "name": "handle_redirects", + "schema": "", + "columns": { + "old_handle": { + "name": "old_handle", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "handle_redirects_target_user_id_idx": { + "name": "handle_redirects_target_user_id_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "handle_redirects_target_user_id_users_id_fk": { + "name": "handle_redirects_target_user_id_users_id_fk", + "tableFrom": "handle_redirects", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "handle_redirects_old_handle_lowercase": { + "name": "handle_redirects_old_handle_lowercase", + "value": "\"handle_redirects\".\"old_handle\" = lower(\"handle_redirects\".\"old_handle\")" + } + }, + "isRLSEnabled": false + }, + "public.pending_uploads": { + "name": "pending_uploads", + "schema": "", + "columns": { + "staging_key": { + "name": "staging_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_uploads_user_id_size_bytes_idx": { + "name": "pending_uploads_user_id_size_bytes_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_bytes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_uploads_user_id_expires_at_idx": { + "name": "pending_uploads_user_id_expires_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_uploads_user_id_users_id_fk": { + "name": "pending_uploads_user_id_users_id_fk", + "tableFrom": "pending_uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_uploads_size_positive": { + "name": "pending_uploads_size_positive", + "value": "\"pending_uploads\".\"size_bytes\" > 0" + }, + "pending_uploads_window_forward": { + "name": "pending_uploads_window_forward", + "value": "\"pending_uploads\".\"expires_at\" >= \"pending_uploads\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.places": { + "name": "places", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "place_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_folded": { + "name": "name_folded", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commune": { + "name": "commune", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county_kind": { + "name": "county_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voivodeship": { + "name": "voivodeship", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "as_of": { + "name": "as_of", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "places_name_folded_prefix_idx": { + "name": "places_name_folded_prefix_idx", + "columns": [ + { + "expression": "name_folded", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_pattern_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "places_kind_idx": { + "name": "places_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "places_name_not_blank": { + "name": "places_name_not_blank", + "value": "length(btrim(\"places\".\"name\")) > 0" + }, + "places_rank_range": { + "name": "places_rank_range", + "value": "\"places\".\"rank\" BETWEEN 0 AND 9" + }, + "places_county_kind_known": { + "name": "places_county_kind_known", + "value": "\"places\".\"county_kind\" IS NULL OR \"places\".\"county_kind\" IN ('county', 'cityCounty')" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle_changed_at": { + "name": "handle_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_file_id": { + "name": "avatar_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locations": { + "name": "locations", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_file_id": { + "name": "cover_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "profiles_handle_unique": { + "name": "profiles_handle_unique", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_avatar_file_id_idx": { + "name": "profiles_avatar_file_id_idx", + "columns": [ + { + "expression": "avatar_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_cover_file_id_idx": { + "name": "profiles_cover_file_id_idx", + "columns": [ + { + "expression": "cover_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "profiles_avatar_file_id_files_id_fk": { + "name": "profiles_avatar_file_id_files_id_fk", + "tableFrom": "profiles", + "tableTo": "files", + "columnsFrom": [ + "avatar_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "profiles_cover_file_id_files_id_fk": { + "name": "profiles_cover_file_id_files_id_fk", + "tableFrom": "profiles", + "tableTo": "files", + "columnsFrom": [ + "cover_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_handle_lowercase": { + "name": "profiles_handle_lowercase", + "value": "\"profiles\".\"handle\" = lower(\"profiles\".\"handle\")" + }, + "profiles_display_name_not_blank": { + "name": "profiles_display_name_not_blank", + "value": "length(btrim(\"profiles\".\"display_name\")) > 0" + }, + "profiles_headline_length": { + "name": "profiles_headline_length", + "value": "\"profiles\".\"headline\" IS NULL OR length(\"profiles\".\"headline\") <= 220" + }, + "profiles_bio_length": { + "name": "profiles_bio_length", + "value": "\"profiles\".\"bio\" IS NULL OR length(\"profiles\".\"bio\") <= 1500" + }, + "profiles_locations_count": { + "name": "profiles_locations_count", + "value": "cardinality(\"profiles\".\"locations\") <= 8" + }, + "profiles_locations_no_null": { + "name": "profiles_locations_no_null", + "value": "array_position(\"profiles\".\"locations\", NULL) IS NULL" + }, + "profiles_locations_total_length": { + "name": "profiles_locations_total_length", + "value": "length(array_to_string(\"profiles\".\"locations\", '')) <= 640" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factors": { + "name": "two_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "two_factors_user_id_unique": { + "name": "two_factors_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factors_user_id_users_id_fk": { + "name": "two_factors_user_id_users_id_fk", + "tableFrom": "two_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_images": { + "name": "work_images", + "schema": "", + "columns": { + "work_id": { + "name": "work_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secondary_file_id": { + "name": "secondary_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "work_images_secondary_file_id_idx": { + "name": "work_images_secondary_file_id_idx", + "columns": [ + { + "expression": "secondary_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_images_work_id_file_id_unique": { + "name": "work_images_work_id_file_id_unique", + "columns": [ + { + "expression": "work_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_images_file_id_idx": { + "name": "work_images_file_id_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_images_work_id_works_id_fk": { + "name": "work_images_work_id_works_id_fk", + "tableFrom": "work_images", + "tableTo": "works", + "columnsFrom": [ + "work_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_images_file_id_files_id_fk": { + "name": "work_images_file_id_files_id_fk", + "tableFrom": "work_images", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "work_images_secondary_file_id_files_id_fk": { + "name": "work_images_secondary_file_id_files_id_fk", + "tableFrom": "work_images", + "tableTo": "files", + "columnsFrom": [ + "secondary_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "work_images_work_id_position_pk": { + "name": "work_images_work_id_position_pk", + "columns": [ + "work_id", + "position" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "work_images_secondary_differs": { + "name": "work_images_secondary_differs", + "value": "\"work_images\".\"secondary_file_id\" IS NULL OR \"work_images\".\"secondary_file_id\" <> \"work_images\".\"file_id\"" + }, + "work_images_position_range": { + "name": "work_images_position_range", + "value": "\"work_images\".\"position\" >= 0 AND \"work_images\".\"position\" <= 2" + } + }, + "isRLSEnabled": false + }, + "public.works": { + "name": "works", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "investor": { + "name": "investor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developer": { + "name": "developer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r360_set_id": { + "name": "r360_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r360_params": { + "name": "r360_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "r360_key_prefix": { + "name": "r360_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "works_user_id_position_idx": { + "name": "works_user_id_position_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "works_r360_set_id_unique": { + "name": "works_r360_set_id_unique", + "columns": [ + { + "expression": "r360_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "works_user_id_users_id_fk": { + "name": "works_user_id_users_id_fk", + "tableFrom": "works", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "works_r360_set_pairing": { + "name": "works_r360_set_pairing", + "value": "(\"works\".\"r360_set_id\" IS NULL) = (\"works\".\"r360_params\" IS NULL)" + }, + "works_r360_set_id_format": { + "name": "works_r360_set_id_format", + "value": "\"works\".\"r360_set_id\" IS NULL OR \"works\".\"r360_set_id\" ~ '^[0-9a-f]{32}$'" + }, + "works_r360_key_prefix_names_the_set": { + "name": "works_r360_key_prefix_names_the_set", + "value": "\"works\".\"r360_key_prefix\" IS NULL OR (\"works\".\"r360_set_id\" IS NOT NULL AND right(\"works\".\"r360_key_prefix\", 39) = '/r360/' || \"works\".\"r360_set_id\" || '/')" + }, + "works_name_not_blank": { + "name": "works_name_not_blank", + "value": "length(btrim(\"works\".\"name\")) > 0" + }, + "works_name_length": { + "name": "works_name_length", + "value": "length(\"works\".\"name\") <= 120" + }, + "works_investor_length": { + "name": "works_investor_length", + "value": "\"works\".\"investor\" IS NULL OR length(\"works\".\"investor\") <= 120" + }, + "works_developer_length": { + "name": "works_developer_length", + "value": "\"works\".\"developer\" IS NULL OR length(\"works\".\"developer\") <= 120" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.file_kind": { + "name": "file_kind", + "schema": "public", + "values": [ + "avatar-original", + "avatar-512", + "avatar-128", + "cover-original", + "cover-1600", + "cover-480", + "work-original", + "work-1600", + "work-480", + "r360-zip", + "r360-1600", + "r360-800" + ] + }, + "public.place_kind": { + "name": "place_kind", + "schema": "public", + "values": [ + "voivodeship", + "county", + "commune", + "city", + "village", + "settlement", + "part" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ad6663b..d599a38 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1789077950571, "tag": "0018_r360_key_prefix", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1789186112096, + "tag": "0019_works_position", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/e2e/db/profile-sections.spec.ts b/e2e/db/profile-sections.spec.ts index e5127ec..a537829 100644 --- a/e2e/db/profile-sections.spec.ts +++ b/e2e/db/profile-sections.spec.ts @@ -184,3 +184,51 @@ test("a signed-out visitor reads the sections, with the bio's paragraphs kept an await visitor.close(); } }); + +test("the places are put in order and stay in it, for the owner and for a visitor (#66)", async ({ + browser, +}) => { + await page.goto(`/${identity.handle}`); + await page.getByRole("button", { name: "Edytuj profil" }).click(); + + const chips = page.locator("li:has(button[aria-label^='Przesuń miejsce'])"); + expect(await chips.allInnerTexts()).toEqual(["Warszawa", "Nowa Wieś"]); + + // The arrow keys on the grip, which is the whole of this for anyone not + // using a mouse — and, unlike a drag, it says what it did out loud. + const saved = page.waitForResponse((response) => + response.url().includes("/api/profile/sections"), + ); + await page.getByRole("button", { name: "Przesuń miejsce Nowa Wieś" }).focus(); + await page.keyboard.press("ArrowLeft"); + expect((await saved).status()).toBe(200); + await expect( + page.getByText("Miejsce Nowa Wieś jest teraz na pozycji 1"), + ).toHaveCount(1); + expect(await chips.allInnerTexts()).toEqual(["Nowa Wieś", "Warszawa"]); + + await page.reload(); + const section = page.locator("section", { + has: page.getByRole("heading", { name: "Siedziba i obszar działania" }), + }); + expect(await section.locator("li").allInnerTexts()).toEqual([ + "Nowa Wieś", + "Warszawa", + ]); + + const visitor = await browser.newPage({ locale: "pl-PL" }); + try { + await visitor.goto(`/${identity.handle}`); + const theirs = visitor.locator("section", { + has: visitor.getByRole("heading", { + name: "Siedziba i obszar działania", + }), + }); + expect(await theirs.locator("li").allInnerTexts()).toEqual([ + "Nowa Wieś", + "Warszawa", + ]); + } finally { + await visitor.close(); + } +}); diff --git a/e2e/db/seed-works.ts b/e2e/db/seed-works.ts index b2bb081..f213582 100644 --- a/e2e/db/seed-works.ts +++ b/e2e/db/seed-works.ts @@ -74,12 +74,18 @@ export async function seedWorks( values ($1, $2, 1, 'work-original', 'png') returning id`, [userId, randomBytes(32).toString("hex")], ); + // #66: the position is written here rather than left to the default, + // because the order these are seeded in is the order the tests expect to + // read back — and with every row sharing a default, the tie would be + // broken by a random uuid. + let position = 0; const photoWork = async (name: string, secondChannel: boolean) => { const fileId = await placeholder(); const secondaryId = secondChannel ? await placeholder() : null; const workId = await insertId( - `insert into works (user_id, name) values ($1, $2) returning id`, - [userId, name], + `insert into works (user_id, name, position) + values ($1, $2, $3) returning id`, + [userId, name, position++], ); await client.query( `insert into work_images (work_id, file_id, secondary_file_id, position) @@ -95,9 +101,9 @@ export async function seedWorks( ...(r360.cues ? { cues: r360.cues } : {}), }; return insertId( - `insert into works (user_id, name, r360_set_id, r360_params) - values ($1, $2, $3, $4) returning id`, - [userId, name, randomBytes(16).toString("hex"), params], + `insert into works (user_id, name, r360_set_id, r360_params, position) + values ($1, $2, $3, $4, $5) returning id`, + [userId, name, randomBytes(16).toString("hex"), params, position++], ); }; const seeded: SeededWork[] = []; diff --git a/e2e/db/works-order.spec.ts b/e2e/db/works-order.spec.ts new file mode 100644 index 0000000..13bc524 --- /dev/null +++ b/e2e/db/works-order.spec.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + completeOnboarding, + logIn, + newIdentity, + registerAndVerify, + type Identity, +} from "./account"; +import { seedWorks } from "./seed-works"; + +// #66: the owner drags a work to the front of their profile and it stays +// there — for them and for a visitor. Both hands are covered, because they +// are different code paths: the pointer (which is what a finger does too) +// and the arrow keys on the same grip, which is the whole of the feature for +// anyone not using a mouse. + +if (process.env.CI && !process.env.DATABASE_URL_TEST?.trim()) { + throw new Error( + "DATABASE_URL_TEST is unset, so the chromium-db project would skip itself. In CI that is a broken workflow: check the step env in .github/workflows/ci.yml.", + ); +} +test.skip( + !process.env.DATABASE_URL_TEST?.trim(), + "no DATABASE_URL_TEST: the owner's page is behind a real session", +); + +test.use({ locale: "pl-PL" }); +test.setTimeout(180_000); +test.describe.configure({ mode: "serial" }); + +const FIRST = "Osiedle nad rzeką"; +const SECOND = "Biurowiec przy rondzie"; +const THIRD = "Dom w lesie"; + +let page: Page; +let identity: Identity; + +/** The work names as the page lists them, top to bottom. */ +async function namesOn(target: Page): Promise { + return target.locator("article h3").allInnerTexts(); +} + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage({ locale: "pl-PL" }); + identity = newIdentity(); + await registerAndVerify(page, identity); + await logIn(page, identity); + await completeOnboarding(page, identity); + await seedWorks(identity.handle, [FIRST, SECOND, THIRD]); +}); + +test.afterAll(async () => { + await page.close(); +}); + +test("the grips appear only while editing", async () => { + await page.goto(`/${identity.handle}`); + expect(await namesOn(page)).toEqual([FIRST, SECOND, THIRD]); + await expect( + page.getByRole("button", { name: /^Przesuń realizację/ }), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Edytuj profil" }).click(); + await expect( + page.getByRole("button", { name: /^Przesuń realizację/ }), + ).toHaveCount(3); +}); + +test("the arrow keys on a grip move a work, and the order survives a reload", async () => { + const last = page.getByRole("button", { + name: `Przesuń realizację ${THIRD}`, + }); + await last.focus(); + // The order is saved as it is dragged, so the request is part of the + // behaviour: a green screen and a silent server is the failure this test + // exists to catch. + const saved = page.waitForResponse((response) => + response.url().includes("/api/works/order"), + ); + await page.keyboard.press("ArrowUp"); + expect((await saved).status()).toBe(200); + // Said out loud, for a screen reader: the cards moving is the only other + // sign that anything happened. + await expect( + page.getByText(`Realizacja ${THIRD} jest teraz na pozycji 2`), + ).toHaveCount(1); + expect(await namesOn(page)).toEqual([FIRST, THIRD, SECOND]); + // The keyboard stays on the work it moved, or a second press would move + // whatever took its place. + await page.keyboard.press("ArrowUp"); + expect(await namesOn(page)).toEqual([THIRD, FIRST, SECOND]); + + await page.reload(); + expect(await namesOn(page)).toEqual([THIRD, FIRST, SECOND]); +}); + +test("a work dragged by its grip lands where it was dropped", async () => { + await page.getByRole("button", { name: "Edytuj profil" }).click(); + const grip = page.getByRole("button", { + name: `Przesuń realizację ${SECOND}`, + }); + // Both ends of the drag have to be on screen at the same time: the mouse + // is moved in viewport coordinates, and a card below the fold would be + // dragged at a point the page never sees. + await grip.scrollIntoViewIfNeeded(); + const first = page.locator("article").first(); + const from = await grip.boundingBox(); + const onto = await first.boundingBox(); + if (!from || !onto) throw new Error("no layout to drag over"); + + const saved = page.waitForResponse((response) => + response.url().includes("/api/works/order"), + ); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + // In steps, as a hand does: the frames in between are where a hand-rolled + // drag tends to lose its grip. + await page.mouse.move(onto.x + onto.width / 2, onto.y + onto.height / 2, { + steps: 8, + }); + await page.mouse.up(); + expect((await saved).status()).toBe(200); + + expect(await namesOn(page)).toEqual([SECOND, THIRD, FIRST]); + await page.reload(); + expect(await namesOn(page)).toEqual([SECOND, THIRD, FIRST]); +}); + +test("a visitor sees the owner's order", async ({ browser }) => { + const visitor = await browser.newPage({ locale: "pl-PL" }); + try { + await visitor.goto(`/${identity.handle}`); + expect(await namesOn(visitor)).toEqual([SECOND, THIRD, FIRST]); + // Nothing to take hold of on someone else's profile. + await expect( + visitor.getByRole("button", { name: /^Przesuń realizację/ }), + ).toHaveCount(0); + } finally { + await visitor.close(); + } +}); diff --git a/messages/en.json b/messages/en.json index ab1fee0..06adbff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -234,6 +234,8 @@ "hint": "Suggestions come from the TERYT register. Enter — or Tab on a phone keyboard — adds your own text, e.g. “all of Poland” or “Berlin”.", "remove": "Remove {place}", "added": "Place added: {place}", + "grip": "Move {place}", + "moved": "{place} is now number {position}", "removed": "Place removed: {place}", "suggestions": "Suggestions", "kind": { @@ -469,6 +471,10 @@ "developer": "Developer", "r360Ready": "R360 ready", "r360None": "No R360", + "move": "Move {name}", + "moved": "{name} is now number {position}", + "moveFailed": "The order could not be saved. Try again.", + "moveRateLimited": "Too many changes at once. Wait a moment.", "orbit": "360° view", "edit": "Edit", "delete": "Delete", diff --git a/messages/pl.json b/messages/pl.json index 08ae55b..9b6e1a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -234,6 +234,8 @@ "hint": "Podpowiedzi z rejestru TERYT: województwa, powiaty, gminy i wszystkie miejscowości. Enter — albo Tab na klawiaturze telefonu — dodaje własny tekst, np. „cała Polska” albo „Berlin”.", "remove": "Usuń {place}", "added": "Dodano miejsce: {place}", + "grip": "Przesuń miejsce {place}", + "moved": "Miejsce {place} jest teraz na pozycji {position}", "removed": "Usunięto miejsce: {place}", "suggestions": "Podpowiedzi", "kind": { @@ -469,6 +471,10 @@ "developer": "Deweloper", "r360Ready": "R360 gotowy", "r360None": "Bez R360", + "move": "Przesuń realizację {name}", + "moved": "Realizacja {name} jest teraz na pozycji {position}", + "moveFailed": "Nie udało się zapisać kolejności. Spróbuj jeszcze raz.", + "moveRateLimited": "Za dużo zmian kolejności naraz. Odczekaj chwilę.", "orbit": "Widok 360°", "edit": "Edytuj", "delete": "Usuń", diff --git a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx index d173431..356ce0e 100644 --- a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx +++ b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx @@ -15,7 +15,9 @@ import { Plaque } from "@/components/ui/plaque"; import { Textarea } from "@/components/ui/textarea"; import { TopBar } from "@/components/ui/top-bar"; import { UploadProgress } from "@/components/ui/upload-progress"; +import { useReorder } from "@/components/ui/use-reorder"; import { postJson } from "@/lib/api-client"; +import { moveItem } from "@/lib/reorder"; import { IMAGE_CONTENT_TYPES } from "@/lib/image-upload-shared"; import { BIO_MAX, @@ -159,6 +161,26 @@ export function OwnerProfileView({ const [locationsError, setLocationsError] = useState(null); /** What a screen reader is told about the place list, out loud (#66). */ const [locationsNotice, setLocationsNotice] = useState(""); + /** #66: the places are dragged into order, and answer the arrow keys. */ + const placeOrder = useReorder({ + count: fields.locations.length, + onMove: (from, to) => movePlace(from, to), + }); + // #66: the works in the order on screen. The prop is the truth until a drag + // moves one — the order shows at once and a refused save puts it back, the + // same bargain the fields on this page make. + const [orderedWorks, setOrderedWorks] = useState(works); + const [syncedWorks, setSyncedWorks] = useState(works); + if (works !== syncedWorks) { + setSyncedWorks(works); + setOrderedWorks(works); + } + const [worksNotice, setWorksNotice] = useState(""); + const [worksOrderError, setWorksOrderError] = useState(null); + const workOrder = useReorder({ + count: orderedWorks.length, + onMove: (from, to) => moveWork(from, to), + }); const [avatarBusy, setAvatarBusy] = useState(false); // #80: the bytes on their way, 0..1, then 1 while the server processes; @@ -307,6 +329,54 @@ export function OwnerProfileView({ ); } + // #66: a work moved. The whole order goes to the server, which refuses one + // about a list that is not the owner's current works — so a second tab + // adding or deleting a work makes this fail rather than shuffle something + // nobody touched. + function moveWork(from: number, to: number) { + const work = orderedWorks[from]; + if (!work) return; + const before = orderedWorks; + const next = moveItem(orderedWorks, from, to); + setOrderedWorks(next); + setWorksOrderError(null); + setWorksNotice(tWorks("card.moved", { name: work.name, position: to + 1 })); + void track( + (async () => { + try { + const response = await postJson("/api/works/order", { + workIds: next.map((one) => one.id), + }); + if (response.ok) return true; + setOrderedWorks(before); + setWorksOrderError( + tWorks( + response.status === 429 + ? "card.moveRateLimited" + : "card.moveFailed", + ), + ); + return false; + } catch { + setOrderedWorks(before); + setWorksOrderError(tWorks("card.moveFailed")); + return false; + } + })(), + ); + } + + // #66: the order of the places IS the array, so putting them in order is + // the same save as adding one — no column, no endpoint of its own. + function movePlace(from: number, to: number) { + const place = fields.locations[from]; + if (place === undefined) return; + setLocationsNotice( + tSections("locations.moved", { place, position: to + 1 }), + ); + void saveLocations(moveItem(fields.locations, from, to)); + } + // The words for a failed upload are shared by the avatar and the cover // (Settings.profile.upload); only the final "point the profile at it" // step has a code of its own per slot. @@ -714,12 +784,20 @@ export function OwnerProfileView({ {tSections("locations.empty")} )} - {fields.locations.map((place) => ( -
  • + {fields.locations.map((place, index) => ( +
  • removePlace(place)} removeLabel={tSections("locations.remove", { place })} + grip={placeOrder.handleProps(index)} + gripLabel={tSections("locations.grip", { place })} + held={placeOrder.dragging === index} + landing={placeOrder.over === index} />
  • ))} @@ -810,8 +888,16 @@ export function OwnerProfileView({ )} {works.length > 0 ? ( tWorks("card.move", { name: work.name }), + } + : undefined + } // #86: the edited work's form stands where its card was. inPlace={ editing && workForm?.kind === "edit" @@ -854,6 +940,14 @@ export function OwnerProfileView({ /> ) )} + {worksOrderError && ( +

    + {worksOrderError} +

    + )} +

    + {worksNotice} +

    diff --git a/src/app/[locale]/(public)/[handle]/profile-sections.tsx b/src/app/[locale]/(public)/[handle]/profile-sections.tsx index fdcfb70..fa6dccd 100644 --- a/src/app/[locale]/(public)/[handle]/profile-sections.tsx +++ b/src/app/[locale]/(public)/[handle]/profile-sections.tsx @@ -51,19 +51,52 @@ export function PlaceChip({ place, onRemove, removeLabel, + grip, + gripLabel, + held = false, + landing = false, }: { place: string; /** Present only while editing: the chip grows an × that removes it. */ onRemove?: () => void; removeLabel?: string; + /** + * Present only while editing (#66): the map pin becomes the grip that drags + * this place up the list, and answers the arrow keys. Whatever `useReorder` + * hands out for this index. + */ + grip?: React.ComponentPropsWithRef<"button">; + gripLabel?: string; + /** This chip is the one being dragged. */ + held?: boolean; + /** This chip is where the dragged one would land. */ + landing?: boolean; }) { return ( - - + + {grip ? ( + + ) : ( + + )} {place} {onRemove && ( + )} + {/* Whether the work has an orbit at all, for the owner's own + glance down the list. */} + + {work.orbit ? t("card.r360Ready") : t("card.r360None")} + +
    {owner.editing && onEdit && onDelete && (
    {confirming ? ( diff --git a/src/app/api/works/order/route.ts b/src/app/api/works/order/route.ts new file mode 100644 index 0000000..e2d9dd2 --- /dev/null +++ b/src/app/api/works/order/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { + checkRateLimit, + parseJsonBody, + rejectCrossSite, + sessionUserId, +} from "@/lib/api-route"; +import { getDb } from "@/db/client"; +import { worksOrderSchema } from "@/lib/work-schemas"; +import { reorderWorks } from "@/lib/works"; +import { respondWithWorkResult } from "../respond"; + +// #66 / A12: the order the owner dragged their works into. The body names +// every work they have, first to last, and lib/works refuses an order about +// any other list — see reorderWorks. +// +// A dragged list saves on every drop, so the limit is looser than the one on +// adding a work: a minute of steady reordering is a plausible thing to do, +// a hundred of them in that minute is not. + +export async function POST(request: Request) { + const userId = await sessionUserId(); + if (!userId) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const crossSite = rejectCrossSite(request); + if (crossSite) return crossSite; + if ( + !checkRateLimit(`works-order:${userId}`, { windowSeconds: 60, max: 60 }) + ) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + + const input = await parseJsonBody(request, worksOrderSchema); + if (!input) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + return respondWithWorkResult(async () => { + await reorderWorks({ db: getDb(), userId }, input.workIds); + }); +} diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 0e02542..9202817 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -23,7 +23,8 @@ type IconName = | "upload" | "layers" | "chevron-up" - | "chevron-down"; + | "chevron-down" + | "grip-vertical"; const PATHS: Record = { "map-pin-off": ( @@ -132,6 +133,17 @@ const PATHS: Record = { ), + // #66: what a list takes hold of to be put in order. + "grip-vertical": ( + <> + + + + + + + + ), }; export function Icon({ diff --git a/src/components/ui/use-reorder.ts b/src/components/ui/use-reorder.ts new file mode 100644 index 0000000..81c6123 --- /dev/null +++ b/src/components/ui/use-reorder.ts @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { indexAtPoint, type Box } from "@/lib/reorder"; + +// #66: the hand that puts a list in order. Two lists use it and they look +// nothing alike — a wrapped row of place chips, a grid of work cards — so this +// knows only about a handle to take hold of and the boxes the items occupy. +// The arithmetic is lib/reorder.ts. +// +// Pointer events, not HTML5 drag-and-drop: `dragstart` never fires on a touch +// screen, and half of this product is used on a phone. One pointer path serves +// a mouse, a finger and a stylus, and the same handle answers the keyboard — +// arrows move the item one place, no grab mode to enter or forget to leave. + +export interface Reorder { + /** The item being dragged right now, or null. */ + dragging: number | null; + /** Where it would land if let go now, or null. */ + over: number | null; + /** Spread onto the element of each item: it is what gets measured. */ + itemProps: (index: number) => { + ref: (node: HTMLElement | null) => void; + }; + /** Spread onto the control inside each item that takes hold of it. */ + handleProps: (index: number) => { + ref: (node: HTMLElement | null) => void; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onPointerCancel: (event: React.PointerEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + style: { touchAction: "none" }; + }; +} + +export function useReorder(options: { + count: number; + /** Called once a move is decided; the caller saves and announces it. */ + onMove: (from: number, to: number) => void; +}): Reorder { + const { count, onMove } = options; + // The drag in flight is kept in refs and mirrored into state. The refs are + // what the handlers read: a pointerup can be delivered before the render + // that a pointermove scheduled has committed, and a handler closed over the + // older render then sees the drag still sitting where it started — so a + // real drag, moved and dropped in one gesture, decided nothing at all. The + // state exists only so the cards can show which is held and where it lands. + const [dragging, setDragging] = useState(null); + const [over, setOver] = useState(null); + const draggingRef = useRef(null); + const overRef = useRef(null); + const items = useRef(new Map()); + const handles = useRef(new Map()); + // Measured once when the drag starts: nothing moves until it ends, and + // measuring per pointer move would read layout dozens of times a second. + const boxes = useRef([]); + // Held in a ref and refreshed after render, the way use-orbit.ts does: + // written during render, a ref is a value the component's output depends on + // without React being told, and the lint says so. + const onMoveRef = useRef(onMove); + useEffect(() => { + onMoveRef.current = onMove; + }, [onMove]); + // The handle to put focus back on after a keyboard move. The list re-renders + // in its new order, so the element that had focus is gone by then — without + // this, one arrow press moves the item and drops the keyboard out of the + // list entirely, which is the whole feature for anyone not using a mouse. + const refocus = useRef(null); + + const keep = useCallback( + (map: React.RefObject>, index: number) => + (node: HTMLElement | null) => { + if (node) map.current.set(index, node); + else map.current.delete(index); + if (map === handles && node && refocus.current === index) { + refocus.current = null; + node.focus(); + } + }, + [], + ); + + const itemProps = useCallback( + (index: number) => ({ ref: keep(items, index) }), + [keep], + ); + + const finish = useCallback(() => { + draggingRef.current = null; + overRef.current = null; + setDragging(null); + setOver(null); + boxes.current = []; + }, []); + + const handleProps = useCallback( + (index: number) => ({ + ref: keep(handles, index), + onPointerDown: (event: React.PointerEvent) => { + if (event.button !== 0 && event.pointerType === "mouse") return; + // Every item or none: a box list with a hole in it would answer the + // wrong index for every item after the hole, and silently. + const measured: Box[] = []; + for (let at = 0; at < count; at++) { + const node = items.current.get(at); + if (!node) return; + measured.push(node.getBoundingClientRect()); + } + // Capture on the handle, so a drag that leaves the list — a finger + // over the page's edge, a mouse over the card next to it — keeps + // being reported here instead of ending where it left. + event.currentTarget.setPointerCapture(event.pointerId); + boxes.current = measured; + draggingRef.current = index; + overRef.current = index; + setDragging(index); + setOver(index); + }, + onPointerMove: (event: React.PointerEvent) => { + if (draggingRef.current === null) return; + const at = indexAtPoint(boxes.current, event.clientX, event.clientY); + overRef.current = at; + setOver(at); + }, + onPointerUp: (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const from = draggingRef.current; + const to = overRef.current; + if (from !== null && to !== null && to !== from) { + onMoveRef.current(from, to); + } + finish(); + }, + // A cancelled pointer is not a decision: the browser took the gesture + // over (a scroll, a back-swipe), and the list stays as it was. + onPointerCancel: finish, + onKeyDown: (event: React.KeyboardEvent) => { + const back = event.key === "ArrowLeft" || event.key === "ArrowUp"; + const on = event.key === "ArrowRight" || event.key === "ArrowDown"; + if (!back && !on) return; + const to = back ? index - 1 : index + 1; + if (to < 0 || to >= count) return; + event.preventDefault(); + refocus.current = to; + onMoveRef.current(index, to); + }, + style: { touchAction: "none" as const }, + }), + [count, finish, keep], + ); + + return { dragging, over, itemProps, handleProps }; +} diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts index 17c0cdf..296d1bc 100644 --- a/src/db/schema.test.ts +++ b/src/db/schema.test.ts @@ -58,6 +58,8 @@ describe("schema tables (SPEC §9)", () => { "r360_params", // #140: where the set's frames are, recorded at save. "r360_key_prefix", + // #66: where the owner put this work among their own. + "position", "updated_at", "user_id", ].sort(), @@ -386,6 +388,9 @@ describe("generated migration SQL (G6 — migrations are the source of truth)", 'DROP CONSTRAINT "works_r360_file_id_files_id_fk"', 'DROP INDEX "works_r360_file_id_idx"', 'DROP INDEX "files_original_user_sha256_unique"', + // 0019 (#66): the works index re-created with the owner's own order in + // front of the adding order it used to carry alone; guarded above. + 'DROP INDEX "works_user_id_created_at_idx"', ...schema.fileKind.enumValues .slice(3) .map((value) => `ALTER TYPE "public"."file_kind" ADD VALUE '${value}'`), diff --git a/src/db/schema.ts b/src/db/schema.ts index 53ba8b0..faa48e5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -260,8 +260,8 @@ export const profiles = pgTable( // #72: a work (realizacja) on a profile. At most 10 per profile — counted // by the application under the same per-user advisory lock the quota uses, -// because a CHECK cannot count rows. Order on the page is the order of -// adding (created_at); a position column arrives with reordering, if ever. +// because a CHECK cannot count rows. Order on the page was the order of +// adding until #66; it is now the owner's, and `position` carries it. // Cascade from users on purpose: a work is nothing but profile content, and // the files it points at are what actually blocks a user's deletion (their // rows restrict), which forces the object cleanup through code (G2). @@ -297,12 +297,23 @@ export const works = pgTable( // catching up. Null on a row the backfill could not place — the reader // falls back to the rebuilt prefix, which is what it did before. r360KeyPrefix: text("r360_key_prefix"), + // #66: where the owner put this work among their own. Not unique and not + // dense on purpose — the order is rewritten wholesale whenever it + // changes, so a gap or a repeat costs nothing but a tie, and created_at + // breaks that. Backfilled from created_at, so nothing shuffled the day + // the column arrived; a new work takes the next number after the last. + position: integer("position").notNull().default(0), createdAt: createdAt(), updatedAt: updatedAt(), }, (table) => [ - // The profile page lists a user's works in adding order. - index("works_user_id_created_at_idx").on(table.userId, table.createdAt), + // The profile page lists a user's works in the order the owner chose, + // adding order behind it (#66). + index("works_user_id_position_idx").on( + table.userId, + table.position, + table.createdAt, + ), // #102 review: a set belongs to one work — the second save of one set // is refused by the code and, should it slip past, by the database. uniqueIndex("works_r360_set_id_unique").on(table.r360SetId), diff --git a/src/lib/reorder.test.ts b/src/lib/reorder.test.ts new file mode 100644 index 0000000..82d2b5c --- /dev/null +++ b/src/lib/reorder.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { indexAtPoint, moveItem, type Box } from "./reorder"; + +// #66: the arithmetic under both reorderable lists — the places on a profile +// and the works below them. + +describe("moveItem", () => { + const list = ["a", "b", "c", "d"]; + + it("takes the last to the front, which is the thing this exists for", () => { + expect(moveItem(list, 3, 0)).toEqual(["d", "a", "b", "c"]); + }); + + it("closes the gap behind and makes room in front, both ways", () => { + expect(moveItem(list, 0, 2)).toEqual(["b", "c", "a", "d"]); + expect(moveItem(list, 2, 1)).toEqual(["a", "c", "b", "d"]); + expect(moveItem(list, 1, 3)).toEqual(["a", "c", "d", "b"]); + }); + + it("gives back the same order when there is nowhere to go", () => { + expect(moveItem(list, 2, 2)).toEqual(list); + expect(moveItem(list, -1, 0)).toEqual(list); + expect(moveItem(list, 0, 4)).toEqual(list); + expect(moveItem(list, 9, 9)).toEqual(list); + expect(moveItem([], 0, 0)).toEqual([]); + expect(moveItem(["only"], 0, 0)).toEqual(["only"]); + }); + + it("never hands back the list it was given", () => { + const next = moveItem(list, 1, 1); + expect(next).not.toBe(list); + next[0] = "changed"; + expect(list[0]).toBe("a"); + }); +}); + +describe("indexAtPoint", () => { + // Two chips side by side with a gap, and a third wrapped onto a second row. + const row: Box[] = [ + { left: 0, top: 0, right: 100, bottom: 40 }, + { left: 120, top: 0, right: 220, bottom: 40 }, + { left: 0, top: 60, right: 100, bottom: 100 }, + ]; + + it("answers the box the point is inside, edges included", () => { + expect(indexAtPoint(row, 50, 20)).toBe(0); + expect(indexAtPoint(row, 150, 20)).toBe(1); + expect(indexAtPoint(row, 50, 80)).toBe(2); + expect(indexAtPoint(row, 0, 0)).toBe(0); + expect(indexAtPoint(row, 220, 40)).toBe(1); + }); + + it("answers the nearest middle in the air between and around them", () => { + // In the gap, but closer to the first chip's middle. + expect(indexAtPoint(row, 105, 20)).toBe(0); + expect(indexAtPoint(row, 118, 20)).toBe(1); + // Below everything, and off to the right of everything. + expect(indexAtPoint(row, 50, 400)).toBe(2); + expect(indexAtPoint(row, 900, 20)).toBe(1); + }); + + it("has no answer only when there is nothing to be over", () => { + expect(indexAtPoint([], 10, 10)).toBeNull(); + }); + + it("gives a tie to the earlier item, so the answer does not depend on measuring order", () => { + const twins: Box[] = [ + { left: 0, top: 0, right: 100, bottom: 40 }, + { left: 200, top: 0, right: 300, bottom: 40 }, + ]; + // Exactly between the two middles: 50 and 250, so 150. + expect(indexAtPoint(twins, 150, 20)).toBe(0); + }); +}); diff --git a/src/lib/reorder.ts b/src/lib/reorder.ts new file mode 100644 index 0000000..5544d69 --- /dev/null +++ b/src/lib/reorder.ts @@ -0,0 +1,74 @@ +// #66: the arithmetic of putting a list in order by hand. Two lists want it — +// the places on a profile and the works below them — and they look nothing +// alike: one is a wrapped row of chips, the other a grid of cards. What they +// share is here, pure and tested; the hand on the pointer is +// components/ui/use-reorder.ts. + +/** A box on the page, as `getBoundingClientRect` gives it. */ +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * The list with the item at `from` moved to `to`, the rest closing the gap + * behind it and making room in front. Out-of-range indices and a move to + * where it already is give the list back unchanged — a caller that computed + * an index from a pointer should not have to check first. + */ +export function moveItem(list: readonly T[], from: number, to: number): T[] { + const last = list.length - 1; + if (from < 0 || from > last || to < 0 || to > last || from === to) { + return [...list]; + } + const next = [...list]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +} + +/** Whether the point is inside the box, edges counting as inside. */ +function contains(box: Box, x: number, y: number): boolean { + return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom; +} + +/** How far the point is from the box's middle, squared (the root is never + * needed: only the order of the distances matters). */ +function fromCentre(box: Box, x: number, y: number): number { + const dx = (box.left + box.right) / 2 - x; + const dy = (box.top + box.bottom) / 2 - y; + return dx * dx + dy * dy; +} + +/** + * Which item a pointer is over: the box it is inside, or failing that the box + * whose middle it is nearest. The fallback is what makes a drag usable — a + * finger in the gap between two chips, or below the last row of a grid, is + * still asking for somewhere in particular, and a drag that only answers while + * exactly over an item stalls wherever the layout has air in it. + * + * Null only when there is nothing to be over. Ties go to the earlier item, so + * the answer does not depend on the order boxes happen to be measured in. + */ +export function indexAtPoint( + boxes: readonly Box[], + x: number, + y: number, +): number | null { + if (boxes.length === 0) return null; + for (const [index, box] of boxes.entries()) { + if (contains(box, x, y)) return index; + } + let nearest = 0; + let best = fromCentre(boxes[0], x, y); + for (let index = 1; index < boxes.length; index++) { + const distance = fromCentre(boxes[index], x, y); + if (distance < best) { + best = distance; + nearest = index; + } + } + return nearest; +} diff --git a/src/lib/work-schemas.ts b/src/lib/work-schemas.ts index efec762..a3b297d 100644 --- a/src/lib/work-schemas.ts +++ b/src/lib/work-schemas.ts @@ -87,6 +87,25 @@ export const workInputSchema = z { message: "duplicate photo", path: ["secondaryFileIds"] }, ); +/** + * #66: the owner's order, as the ids of every work they have, first to last. + * Whole rather than a pair of indices: the client already knows the list it + * is looking at, and a request that names all of it can be checked against + * what the database holds instead of trusted to be about the same list. + */ +export const worksOrderSchema = z + .object({ + workIds: z + .array(z.uuid()) + .min(1) + .max(WORKS_MAX) + .refine((ids) => new Set(ids).size === ids.length, { + message: "duplicate work", + }), + }) + .strict(); +export type WorksOrderInput = z.input; + /** The second channels of a parsed work, one per photo (null = none). */ export function secondariesOf(work: { imageFileIds: string[]; diff --git a/src/lib/works.ts b/src/lib/works.ts index efa7acd..4c2d148 100644 --- a/src/lib/works.ts +++ b/src/lib/works.ts @@ -44,6 +44,8 @@ export class WorksError extends Error { | "invalid_archive" /** #102: a frame set kept while its archive changed. */ | "invalid_set" + /** #66: an order about a list of works that is not the one there now. */ + | "stale_order" | "not_found", ) { super(`work rejected: ${code}`); @@ -91,7 +93,12 @@ export interface WorkView { const WORK_VARIANTS = IMAGE_PROFILES.work.variants; -/** A user's works in adding order, with their photos' public URLs. */ +/** + * A user's works in the order the owner put them in, with their photos' + * public URLs. Adding order breaks a tie (#66): positions are rewritten + * wholesale, so they are neither unique nor dense, and two works that have + * never been reordered both sit at 0. + */ export async function listWorks(deps: ProfileReadDeps): Promise { const { db, storage, prefix, userId } = deps; const rows = await db @@ -106,7 +113,7 @@ export async function listWorks(deps: ProfileReadDeps): Promise { }) .from(works) .where(eq(works.userId, userId)) - .orderBy(asc(works.createdAt), asc(works.id)); + .orderBy(asc(works.position), asc(works.createdAt), asc(works.id)); if (rows.length === 0) return []; const workIds = rows.map((row) => row.id); @@ -331,8 +338,14 @@ export async function createWork( return withFrameSet(deps, parsed, (verified) => db.transaction(async (tx) => { await lockUser(tx, userId); - const [{ count }] = await tx - .select({ count: sql`count(*)::int` }) + // #66: the count and the last position in one pass, under the lock + // that already serialises adds — a new work goes after the ones there, + // wherever the owner has since dragged them. + const [{ count, lastPosition }] = await tx + .select({ + count: sql`count(*)::int`, + lastPosition: sql`coalesce(max(${works.position}), -1)::int`, + }) .from(works) .where(eq(works.userId, userId)); if (count >= WORKS_MAX) throw new WorksError("limit"); @@ -344,6 +357,7 @@ export async function createWork( userId, ...workColumnsOf(parsed), r360KeyPrefix: verified?.keyPrefix ?? null, + position: lastPosition + 1, }) .returning({ id: works.id }); await insertImageRows(tx, created.id, parsed); @@ -353,6 +367,43 @@ export async function createWork( ); } +/** + * #66: the owner's order for their own works, first to last. + * + * The request names every work the owner has, and this refuses anything else + * — an order written while another tab was adding or deleting one would + * otherwise be applied to a list it was never about, quietly moving works + * the owner never touched. The client refreshes and the owner drags again, + * which is the honest outcome of two tabs disagreeing. + * + * Positions are rewritten wholesale rather than shuffled, so they stay dense + * and gap-free; under the same per-user lock as every other works write. + */ +export async function reorderWorks( + deps: Pick, + workIds: string[], +): Promise { + const { db, userId } = deps; + await db.transaction(async (tx) => { + await lockUser(tx, userId); + const own = await tx + .select({ id: works.id }) + .from(works) + .where(eq(works.userId, userId)); + const mine = new Set(own.map((row) => row.id)); + const asked = new Set(workIds); + if (mine.size !== asked.size || workIds.some((id) => !mine.has(id))) { + throw new WorksError("stale_order"); + } + for (const [position, id] of workIds.entries()) { + await tx + .update(works) + .set({ position }) + .where(and(eq(works.id, id), eq(works.userId, userId))); + } + }); +} + /** * Replaces a work's fields, photos and orbit. The photos are a * delete-and-reinsert in one transaction: the (work_id, position) key is From a60ff48ba5454c2142d66249810834c26efc3b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 06:59:20 +0200 Subject: [PATCH 3/3] What the reviews found in the reorder (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lanes went over it; the endpoint came back clean, the hand on the list did not. - **A refused save could resurrect a deleted work.** The revert put back a snapshot taken before the save — and if the list had changed underneath (another tab deleted a work, which is exactly what `stale_order` is for), that snapshot brought the deleted one back on screen with live controls, permanently: the derived-state resync had already fired, so nothing would correct it short of a page load. Nothing is reverted from a snapshot now. A failure says so and refreshes, and the server's answer is the way back. - **Saves were not sequenced.** An arrow key repeats about twenty-five times a second and each press was its own request: several in flight, committed in whatever order they reached the lock, the tail of them refused by the rate limit — and each refusal reverting to its own stale snapshot. The order is now sent once the moving stops, and "Zapisz" flushes a waiting one rather than claiming "Zapisano" over it. - **An open work form disabled every pointer drag, silently.** Its row is not one of the measured boxes, so the measuring loop bailed for every grip while the grips stayed there looking draggable. No reordering while a form stands in the list, grips and all. - **A second finger took over a drag**, and the first one's release then committed the second's half-finished move. - The order error outlived editing and was never cleared. - Capture lost without a cancel (the grip unmounting mid-drag) left a card looking held until the next drag. Accessibility, which no e2e test would have caught: a grip is a button, and its keys — Enter, Space — move nothing, so both lists now name the arrows in a line every grip points at, and every grip says where its item sits ("2 of 5"). What remains and is written down rather than fixed: in a screen reader's browse mode the arrows are consumed before they reach the button, so the conventional pick-up-and-drop model is the fuller answer. Also from the security lane: the ten UPDATEs are one statement now, so the per-user lock and the pooled connection are held for a round trip rather than a dozen. The owner stays in the WHERE. Co-Authored-By: Claude Opus 5 --- messages/en.json | 6 +- messages/pl.json | 6 +- .../(public)/[handle]/owner-profile-view.tsx | 96 ++++++++++++++++--- .../(public)/[handle]/profile-sections.tsx | 4 + .../(public)/[handle]/works-gallery.tsx | 28 ++++-- src/components/ui/use-reorder.ts | 17 ++++ src/lib/works.ts | 20 ++-- 7 files changed, 146 insertions(+), 31 deletions(-) diff --git a/messages/en.json b/messages/en.json index 06adbff..fa066e4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -234,7 +234,7 @@ "hint": "Suggestions come from the TERYT register. Enter — or Tab on a phone keyboard — adds your own text, e.g. “all of Poland” or “Berlin”.", "remove": "Remove {place}", "added": "Place added: {place}", - "grip": "Move {place}", + "grip": "Move {place}, {position} of {count}", "moved": "{place} is now number {position}", "removed": "Place removed: {place}", "suggestions": "Suggestions", @@ -471,7 +471,9 @@ "developer": "Developer", "r360Ready": "R360 ready", "r360None": "No R360", - "move": "Move {name}", + "move": "Move {name}, {position} of {count}", + "moveHint": "Use the arrow keys to move it forward and back.", + "moveStale": "The list of works changed elsewhere. Reloading it — set the order again.", "moved": "{name} is now number {position}", "moveFailed": "The order could not be saved. Try again.", "moveRateLimited": "Too many changes at once. Wait a moment.", diff --git a/messages/pl.json b/messages/pl.json index 9b6e1a8..97cdfe4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -234,7 +234,7 @@ "hint": "Podpowiedzi z rejestru TERYT: województwa, powiaty, gminy i wszystkie miejscowości. Enter — albo Tab na klawiaturze telefonu — dodaje własny tekst, np. „cała Polska” albo „Berlin”.", "remove": "Usuń {place}", "added": "Dodano miejsce: {place}", - "grip": "Przesuń miejsce {place}", + "grip": "Przesuń miejsce {place}, {position} z {count}", "moved": "Miejsce {place} jest teraz na pozycji {position}", "removed": "Usunięto miejsce: {place}", "suggestions": "Podpowiedzi", @@ -471,7 +471,9 @@ "developer": "Deweloper", "r360Ready": "R360 gotowy", "r360None": "Bez R360", - "move": "Przesuń realizację {name}", + "move": "Przesuń realizację {name}, {position} z {count}", + "moveHint": "Strzałkami przesuniesz w przód i w tył listy.", + "moveStale": "Lista realizacji zmieniła się gdzie indziej. Odświeżamy ją — ustaw kolejność jeszcze raz.", "moved": "Realizacja {name} jest teraz na pozycji {position}", "moveFailed": "Nie udało się zapisać kolejności. Spróbuj jeszcze raz.", "moveRateLimited": "Za dużo zmian kolejności naraz. Odczekaj chwilę.", diff --git a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx index 356ce0e..807cd92 100644 --- a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx +++ b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx @@ -74,6 +74,9 @@ function fieldsOf(profile: OwnerProfile): Fields { }; } +/** #66: how long the order waits for the moving to stop before it is sent. */ +const ORDER_SAVE_AFTER_MS = 300; + type SectionsErrorKey = "invalid" | "rateLimited" | "generic"; // The one call every section makes: a subset of the A12 fields, answered @@ -171,12 +174,20 @@ export function OwnerProfileView({ // same bargain the fields on this page make. const [orderedWorks, setOrderedWorks] = useState(works); const [syncedWorks, setSyncedWorks] = useState(works); - if (works !== syncedWorks) { + // An order the server has not acknowledged yet. While one stands, a + // refresh from somewhere else on this page (an avatar landing, a work + // saved) must not re-seed the list under it and throw the move away. + const [orderDirty, setOrderDirty] = useState(false); + if (works !== syncedWorks && !orderDirty) { setSyncedWorks(works); setOrderedWorks(works); } const [worksNotice, setWorksNotice] = useState(""); const [worksOrderError, setWorksOrderError] = useState(null); + const pendingOrder = useRef(null); + const orderTimer = useRef | undefined>( + undefined, + ); const workOrder = useReorder({ count: orderedWorks.length, onMove: (from, to) => moveWork(from, to), @@ -200,6 +211,8 @@ export function OwnerProfileView({ const headlineId = useId(); const bioId = useId(); const locationsId = useId(); + /** #66: the one line telling a keyboard which keys move a thing. */ + const moveHintId = useId(); // Saves still in flight, each resolving to whether it succeeded, so // "Zapisz" can wait for them and stay open if one failed. @@ -333,35 +346,71 @@ export function OwnerProfileView({ // about a list that is not the owner's current works — so a second tab // adding or deleting a work makes this fail rather than shuffle something // nobody touched. + // + // Sent once the moving stops, not once per move: an arrow key repeats + // about twenty-five times a second, and each press would otherwise be its + // own request — several in flight at once, committed in whatever order + // they reach the lock, with the rate limit refusing the tail of them. function moveWork(from: number, to: number) { const work = orderedWorks[from]; - if (!work) return; - const before = orderedWorks; + if (work === undefined) return; const next = moveItem(orderedWorks, from, to); setOrderedWorks(next); setWorksOrderError(null); setWorksNotice(tWorks("card.moved", { name: work.name, position: to + 1 })); - void track( + queueOrderSave(next); + } + + function queueOrderSave(next: GalleryWork[]) { + pendingOrder.current = next; + setOrderDirty(true); + if (orderTimer.current !== undefined) clearTimeout(orderTimer.current); + orderTimer.current = setTimeout(() => { + void saveWorkOrder(); + }, ORDER_SAVE_AFTER_MS); + } + + /** + * Sends the newest order, if one is waiting. Resolves to whether the list + * on screen is what the server holds, so "Zapisz" can wait for it. + * + * Nothing is reverted from a snapshot on failure: by the time a refused + * save comes back, the list may have changed for a reason — a work deleted + * in another tab is exactly why `stale_order` exists — and putting an old + * copy back would resurrect it on screen, with its buttons, until a full + * page load. The server's own answer is the way back. + */ + function saveWorkOrder(): Promise { + if (orderTimer.current !== undefined) { + clearTimeout(orderTimer.current); + orderTimer.current = undefined; + } + const next = pendingOrder.current; + if (next === null) return Promise.resolve(true); + pendingOrder.current = null; + return track( (async () => { try { const response = await postJson("/api/works/order", { workIds: next.map((one) => one.id), }); - if (response.ok) return true; - setOrderedWorks(before); + if (response.ok) { + setOrderDirty(false); + return true; + } setWorksOrderError( tWorks( response.status === 429 ? "card.moveRateLimited" - : "card.moveFailed", + : "card.moveStale", ), ); - return false; } catch { - setOrderedWorks(before); setWorksOrderError(tWorks("card.moveFailed")); - return false; } + setOrderDirty(false); + router.refresh(); + return false; })(), ); } @@ -502,6 +551,9 @@ export function OwnerProfileView({ if (outcome === "kept") return; if (outcome === "closed") setWorkForm(null); } + // A waiting order goes now rather than on its timer, or "Zapisano" + // would be claimed over a save that has not left the page (#66). + await saveWorkOrder(); const outcomes = await Promise.all([...pending.current]); if (outcomes.some((ok) => !ok)) return; setEditing(false); @@ -521,6 +573,7 @@ export function OwnerProfileView({ setHeadlineError(null); setBioError(null); setLocationsError(null); + setWorksOrderError(null); } return ( @@ -795,7 +848,12 @@ export function OwnerProfileView({ onRemove={() => removePlace(place)} removeLabel={tSections("locations.remove", { place })} grip={placeOrder.handleProps(index)} - gripLabel={tSections("locations.grip", { place })} + gripLabel={tSections("locations.grip", { + place, + position: index + 1, + count: fields.locations.length, + })} + gripHint={moveHintId} held={placeOrder.dragging === index} landing={placeOrder.over === index} /> @@ -894,7 +952,13 @@ export function OwnerProfileView({ editing ? { reorder: workOrder, - label: (work) => tWorks("card.move", { name: work.name }), + label: (work, at, of) => + tWorks("card.move", { + name: work.name, + position: at + 1, + count: of, + }), + describedBy: moveHintId, } : undefined } @@ -940,7 +1004,7 @@ export function OwnerProfileView({ /> ) )} - {worksOrderError && ( + {editing && worksOrderError && (

    {worksOrderError}

    @@ -948,6 +1012,12 @@ export function OwnerProfileView({

    {worksNotice}

    + {/* Named by every grip on the page (#66). A grip is a button, and + a button's keys are Enter and Space — neither of which moves + anything here, so the arrows have to be said out loud. */} +

    + {tWorks("card.moveHint")} +

    diff --git a/src/app/[locale]/(public)/[handle]/profile-sections.tsx b/src/app/[locale]/(public)/[handle]/profile-sections.tsx index fa6dccd..12a5e8e 100644 --- a/src/app/[locale]/(public)/[handle]/profile-sections.tsx +++ b/src/app/[locale]/(public)/[handle]/profile-sections.tsx @@ -53,6 +53,7 @@ export function PlaceChip({ removeLabel, grip, gripLabel, + gripHint, held = false, landing = false, }: { @@ -67,6 +68,8 @@ export function PlaceChip({ */ grip?: React.ComponentPropsWithRef<"button">; gripLabel?: string; + /** The element saying which keys move it, for a screen reader. */ + gripHint?: string; /** This chip is the one being dragged. */ held?: boolean; /** This chip is where the dragged one would land. */ @@ -85,6 +88,7 @@ export function PlaceChip({ type="button" {...grip} aria-label={gripLabel} + aria-describedby={gripHint} title={gripLabel} className="-ml-1 flex h-5 w-5 cursor-grab items-center justify-center rounded-full text-(--text-subtle) hover:bg-(--surface-sunken) hover:text-(--text-body) focus-visible:shadow-[var(--ring-focus)] focus-visible:outline-none active:cursor-grabbing" > diff --git a/src/app/[locale]/(public)/[handle]/works-gallery.tsx b/src/app/[locale]/(public)/[handle]/works-gallery.tsx index a0294cc..b93c572 100644 --- a/src/app/[locale]/(public)/[handle]/works-gallery.tsx +++ b/src/app/[locale]/(public)/[handle]/works-gallery.tsx @@ -143,8 +143,10 @@ export function WorksGallery({ */ order?: { reorder: Reorder; - /** What the grip on the work at `index` is called. */ - label: (work: GalleryWork) => string; + /** What the grip is called: the work, and where it sits right now. */ + label: (work: GalleryWork, at: number, of: number) => string; + /** Names the element that says how to move it from a keyboard. */ + describedBy: string; }; /** #86: the work being edited shows its form where its card was, the * others stay put — three cards for two works was the wrong picture. */ @@ -153,6 +155,11 @@ export function WorksGallery({ onDelete?: (work: GalleryWork) => Promise; }) { const t = useTranslations("Works"); + // #66: no reordering while a work's form stands in the list. The form's + // row is not one of the measured boxes, so a drag would answer the wrong + // index for everything after it — and the grips stayed there looking + // draggable while pointer drags quietly did nothing. + const reorder = inPlace ? undefined : order; const [lightbox, setLightbox] = useState(null); // #84: on a phone, back is the gesture for "close this picture", and it // used to leave the site. Opening pushes a history entry marked as the @@ -216,7 +223,7 @@ export function WorksGallery({
  • 1 - ? order.reorder.handleProps(at) + reorder && works.length > 1 + ? reorder.reorder.handleProps(at) : undefined } - gripLabel={order?.label(work)} - held={order?.reorder.dragging === at} - landing={order?.reorder.over === at} + gripLabel={reorder?.label(work, at, works.length)} + gripHint={reorder?.describedBy} + held={reorder?.reorder.dragging === at} + landing={reorder?.reorder.over === at} />
  • ), @@ -264,6 +272,7 @@ function WorkCard({ onDelete, grip, gripLabel, + gripHint, held = false, landing = false, }: { @@ -275,6 +284,8 @@ function WorkCard({ /** #66: whatever `useReorder` hands out for this card's place in the list. */ grip?: React.ComponentPropsWithRef<"button">; gripLabel?: string; + /** The element saying which keys move it, for a screen reader. */ + gripHint?: string; /** This card is the one being dragged. */ held?: boolean; /** This card is where the dragged one would land. */ @@ -429,6 +440,7 @@ function WorkCard({ type="button" {...grip} aria-label={gripLabel} + aria-describedby={gripHint} title={gripLabel} className="flex h-8 w-8 shrink-0 cursor-grab items-center justify-center rounded-sm text-(--text-subtle) hover:bg-(--surface-sunken) hover:text-(--text-body) focus-visible:shadow-[var(--ring-focus)] focus-visible:outline-none active:cursor-grabbing" > diff --git a/src/components/ui/use-reorder.ts b/src/components/ui/use-reorder.ts index 81c6123..7765c50 100644 --- a/src/components/ui/use-reorder.ts +++ b/src/components/ui/use-reorder.ts @@ -29,6 +29,7 @@ export interface Reorder { onPointerMove: (event: React.PointerEvent) => void; onPointerUp: (event: React.PointerEvent) => void; onPointerCancel: (event: React.PointerEvent) => void; + onLostPointerCapture: (event: React.PointerEvent) => void; onKeyDown: (event: React.KeyboardEvent) => void; style: { touchAction: "none" }; }; @@ -68,6 +69,10 @@ export function useReorder(options: { // list entirely, which is the whole feature for anyone not using a mouse. const refocus = useRef(null); + // Deliberately a fresh closure per render: that is what makes React re-run + // it, which is how the focus restore below ever gets a chance to fire. + // Memoizing these — the obvious future tidy-up — silently ends keyboard + // reordering, and no test would notice. const keep = useCallback( (map: React.RefObject>, index: number) => (node: HTMLElement | null) => { @@ -99,6 +104,10 @@ export function useReorder(options: { ref: keep(handles, index), onPointerDown: (event: React.PointerEvent) => { if (event.button !== 0 && event.pointerType === "mouse") return; + // One drag at a time. A second finger on another grip would take the + // drag over, and the first finger's release would then commit the + // second one's half-finished move — and make its own release a no-op. + if (draggingRef.current !== null) return; // Every item or none: a box list with a hole in it would answer the // wrong index for every item after the hole, and silently. const measured: Box[] = []; @@ -120,6 +129,9 @@ export function useReorder(options: { onPointerMove: (event: React.PointerEvent) => { if (draggingRef.current === null) return; const at = indexAtPoint(boxes.current, event.clientX, event.clientY); + // Pointer moves arrive dozens of times a second and every card here + // holds a live orbit: re-render only when the answer actually moves. + if (at === overRef.current) return; overRef.current = at; setOver(at); }, @@ -137,6 +149,11 @@ export function useReorder(options: { // A cancelled pointer is not a decision: the browser took the gesture // over (a scroll, a back-swipe), and the list stays as it was. onPointerCancel: finish, + // Capture lost any other way — the grip unmounted because editing + // ended, or the list shrank under it. `pointercancel` does not fire + // then, and without this the card keeps its held look until the next + // drag. + onLostPointerCapture: finish, onKeyDown: (event: React.KeyboardEvent) => { const back = event.key === "ArrowLeft" || event.key === "ArrowUp"; const on = event.key === "ArrowRight" || event.key === "ArrowDown"; diff --git a/src/lib/works.ts b/src/lib/works.ts index 4c2d148..cc0f3d4 100644 --- a/src/lib/works.ts +++ b/src/lib/works.ts @@ -395,12 +395,20 @@ export async function reorderWorks( if (mine.size !== asked.size || workIds.some((id) => !mine.has(id))) { throw new WorksError("stale_order"); } - for (const [position, id] of workIds.entries()) { - await tx - .update(works) - .set({ position }) - .where(and(eq(works.id, id), eq(works.userId, userId))); - } + // One statement rather than ten: the lock and the pooled connection are + // held for a round trip instead of a dozen. The owner is still in the + // WHERE — the set comparison above is the first answer to "are these + // yours", this is the second, and neither is a comment about the other. + const pairs = sql.join( + workIds.map((id, position) => sql`(${id}::uuid, ${position}::int)`), + sql`, `, + ); + await tx.execute(sql` + update "works" as w + set "position" = v."position" + from (values ${pairs}) as v("id", "position") + where w."id" = v."id" and w."user_id" = ${userId}::uuid + `); }); }