diff --git a/.cursor/rules/form-spacing.mdc b/.cursor/rules/form-spacing.mdc new file mode 100644 index 0000000..5270095 --- /dev/null +++ b/.cursor/rules/form-spacing.mdc @@ -0,0 +1,47 @@ +--- +description: Form spacing hierarchy — small/medium/large rhythm for settings, modals, and auth forms +globs: src/app/**/*.{ts,tsx},src/app/_components/**/*.{ts,tsx} +alwaysApply: false +--- + +# Form spacing + +Import tokens from `~/lib/form-spacing` (`formSpacing.tight` / `.stack` / `.section`). Do not invent one-off `space-y-*` values for form layout. + +## Scale + +| Level | Token | Use for | +|---|---|---| +| Small | `formSpacing.tight` (`space-y-1.5`) | Title→description, label→input, input→helper/error, avatar→upload copy | +| Medium | `formSpacing.stack` (`space-y-5`) | Related controls in the same section (name→username, password fields) | +| Large | `formSpacing.section` (`space-y-8`) | Distinct sections (photo vs profile info) and content→actions | + +Horizontal siblings in a row use the matching `*Gap` token (`tightGap` / `stackGap` / `sectionGap`). + +## Grouping + +Think in logical groups, not identical gaps between every control: + +```tsx +
+ {/* internal: tight */} +
+ + +
+
+``` + +Section chrome (title + description) uses `tight` between those lines, then `stack` before the fields in that section. + +## Avoid + +- Cards, boxed groups, or heavy dividers used only to create hierarchy +- Flat `space-y-4` (or any single value) across an entire form +- Mixing raw Tailwind spacing with the tokens in the same form + +## Primitives + +- `FormItem` already uses the tight scale for label→control→message +- Account settings `SettingsField` / `DialogSection` follow the same tokens +- New settings pages, dialogs, and auth forms should compose the same way diff --git a/.cursor/rules/mobile-drawer-menus.mdc b/.cursor/rules/mobile-drawer-menus.mdc new file mode 100644 index 0000000..eb4beea --- /dev/null +++ b/.cursor/rules/mobile-drawer-menus.mdc @@ -0,0 +1,74 @@ +--- +description: Mobile drill-down drawer menus — use the shared kit; do not reinvent sizing/keyboard behavior +globs: src/app/_components/mobile-drawer/**/*.{ts,tsx},src/app/_components/account-settings/**/*.{ts,tsx},src/app/_components/document-actions.tsx,src/app/_components/document-breadcrumb.tsx,src/app/_components/editor/document-publish-panels.tsx +alwaysApply: false +--- + +# Mobile drawer menus + +Build mobile drill-down menus with `~/app/_components/mobile-drawer`. Defaults already match Account + Document Actions. Prefer composing these primitives over custom Vaul height/keyboard logic. + +## Recipe (new menu) + +1. **Shell** — `MobileMenuDrawer` (keyboard offset + content-sized shell). Do not hand-wire `useMobileDrawerKeyboardOffset` + `MOBILE_DRAWER_SHELL_CLASS` unless you have a special case. +2. **Stage** — `useMobileDrawerStage` + `MobileDrawerViewStack`. Pass `mainView`, `keyboardView` (string or stable array of input screens), and `measureDeps` when main content changes. +3. **Root list** — `MobileDrawerScreenHeader` + `MobileActionGroup` / `MobileActionButtonRow`. +4. **Intermediate list** (optional, e.g. Profile) — `MobileDrawerNavHeader` + rows. Stage auto-grows/shrinks from measured content. +5. **Field / keyboard screen** — `MobileDrawerFieldView` + inputs using `MOBILE_DRAWER_FIELD_INPUT_CLASS`. + +Standalone single-field edit (no stack): `MobileFormDrawer`. + +## Stage views + +| Kind | Examples | Stage behavior | +|---|---|---| +| Main | Account root, Publish root | Measured as baseline | +| Intermediate | Profile list | Remeasured; taller than main is fine | +| Keyboard | First name, Edit URL, Password | Sized to **content + small clearance**; not main/intermediate height | + +`keyboardView` must list every screen with text inputs. Intermediate lists without inputs are **not** keyboard views. + +## Keyboard / leave (required) + +- **Back** from a field screen: always dismiss via `MobileDrawerFieldView` (default) or `useMobileDrawerLeave()` so the previous view does not resize against a shifting visual viewport. +- **Async save then navigate**: `dismissKeyboardOnDone={false}` on `MobileDrawerFieldView`, then `leave(onSaved)` after success (`useMobileDrawerLeave`). +- **Autofocus**: built into `MobileDrawerFieldView` (first `input`/`textarea`). Prefer that over manual focus + timeouts. + +## Do not + +```tsx +// ❌ Full-viewport Vaul height while keyboard is open (fills the screen) +applyMobileDrawerKeyboardInset(); +keyboardShellInset: true; + +// ❌ Predictive tall floor for simple fields +keyboardMinContentPx: 268; + +// ❌ Measure main via stageRef while on an intermediate view (poisons height) +mainMeasureRef.current ?? stageRef.current; + +// ❌ Navigate back while keyboard is up without wait/blur +onBack={stage.returnToView("profile")}; +``` + +```tsx +// ✅ Defaults — content-measured, shell inset off +useMobileDrawerStage({ view, setView, mainView: "main", keyboardView: KEYBOARD_VIEWS }); + +// ✅ Keyboard-safe back + stage.returnToView("profile")} onDone={…}> + + +``` + +## Wiring checklist + +- Open subviews with `stage.measureMainStage()` then `stage.goToView(next, 1)`. +- Back to root: `stage.returnToMainView`. Back to intermediate: `stage.returnToView("profile")`. +- Feature code owns: row labels/icons, field schemas, mutations, save feedback. Kit owns: sizing, transitions, focus, keyboard dismiss. + +## Reference implementations + +- Multi-level + fields: `account-settings/account-settings.tsx`, `mobile-profile-field-edit.tsx` +- Main → keyboard field: `document-actions.tsx` + `editor/document-publish-panels.tsx` +- Standalone field: `document-breadcrumb.tsx` → `MobileFormDrawer` diff --git a/migrations/README.md b/migrations/README.md index 3f7f9bb..d9a5bf5 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -12,3 +12,4 @@ Older tables in your project may have been created before this repo’s filename | File | Purpose | |------|--------| | `document_publications.sql` | Public published docs: `/[owner segment]/[slug]`, RLS | +| `document_publication_redirects.sql` | Path redirects after username/slug changes; redirect-before-publication lookup | diff --git a/migrations/document_publication_redirects.sql b/migrations/document_publication_redirects.sql new file mode 100644 index 0000000..b0750e3 --- /dev/null +++ b/migrations/document_publication_redirects.sql @@ -0,0 +1,99 @@ +-- Path-exact redirects for published docs after owner segment / slug changes. +-- Lookup order on public pages: redirect first, then publication — so old URLs +-- keep resolving even if another user later claims the same username+slug. + +CREATE TABLE IF NOT EXISTS document_publication_redirects ( + from_owner_username TEXT NOT NULL, + from_slug TEXT NOT NULL, + to_owner_username TEXT NOT NULL, + to_slug TEXT NOT NULL, + document_id UUID NOT NULL REFERENCES documents (id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (from_owner_username, from_slug), + CONSTRAINT document_publication_redirects_from_username_format CHECK ( + from_owner_username ~ '^[a-z0-9_-]{2,50}$' + ), + CONSTRAINT document_publication_redirects_to_username_format CHECK ( + to_owner_username ~ '^[a-z0-9_-]{2,50}$' + ), + CONSTRAINT document_publication_redirects_from_slug_format CHECK ( + from_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' + AND char_length(from_slug) BETWEEN 1 AND 200 + ), + CONSTRAINT document_publication_redirects_to_slug_format CHECK ( + to_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' + AND char_length(to_slug) BETWEEN 1 AND 200 + ), + CONSTRAINT document_publication_redirects_not_identity CHECK ( + from_owner_username <> to_owner_username + OR from_slug <> to_slug + ) +); + +CREATE INDEX IF NOT EXISTS document_publication_redirects_to_path_idx + ON document_publication_redirects (to_owner_username, to_slug); + +CREATE INDEX IF NOT EXISTS document_publication_redirects_document_id_idx + ON document_publication_redirects (document_id); + +CREATE INDEX IF NOT EXISTS document_publication_redirects_creator_id_idx + ON document_publication_redirects (creator_id); + +ALTER TABLE document_publication_redirects ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read document publication redirects" +ON document_publication_redirects FOR SELECT USING (true); + +CREATE POLICY "Document editors insert publication redirects" +ON document_publication_redirects FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 + FROM document_permissions dp + WHERE + dp.document_id = document_publication_redirects.document_id + AND dp.user_id = (SELECT auth.uid()) + ) + AND creator_id = ( + SELECT d.creator_id + FROM documents d + WHERE d.id = document_publication_redirects.document_id + ) +); + +CREATE POLICY "Document editors update publication redirects" +ON document_publication_redirects FOR UPDATE USING ( + EXISTS ( + SELECT 1 + FROM document_permissions dp + WHERE + dp.document_id = document_publication_redirects.document_id + AND dp.user_id = (SELECT auth.uid()) + ) +) +WITH CHECK ( + EXISTS ( + SELECT 1 + FROM document_permissions dp + WHERE + dp.document_id = document_publication_redirects.document_id + AND dp.user_id = (SELECT auth.uid()) + ) + AND creator_id = ( + SELECT d.creator_id + FROM documents d + WHERE d.id = document_publication_redirects.document_id + ) +); + +CREATE POLICY "Document editors delete publication redirects" +ON document_publication_redirects FOR DELETE USING ( + EXISTS ( + SELECT 1 + FROM document_permissions dp + WHERE + dp.document_id = document_publication_redirects.document_id + AND dp.user_id = (SELECT auth.uid()) + ) +); diff --git a/package-lock.json b/package-lock.json index 59a1852..e964e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,8 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toast": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.0", - "@supabase/ssr": "^0.5.2", - "@supabase/supabase-js": "^2.46.1", + "@supabase/ssr": "^0.12.4", + "@supabase/supabase-js": "^2.111.0", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "^5.50.0", "@trpc/client": "^11.0.0-rc.446", @@ -3632,90 +3632,112 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.65.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.1.tgz", - "integrity": "sha512-IA7i2Xq2SWNCNMKxwmPlHafBQda0qtnFr8QnyyBr+KaSxoXXqEzFCnQ1dGTy6bsZjVBgXu++o3qrDypTspaAPw==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.111.0.tgz", + "integrity": "sha512-hbRLgyQZEX0SDyF4LYXpv94qOIQyFATfpT5SIs2V0SisHnisVRkYgiPQWzv80l4S2mO3qof78rmoH9j5zoFsYQ==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@supabase/functions-js": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.3.tgz", - "integrity": "sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.111.0.tgz", + "integrity": "sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/node-fetch": { - "version": "2.6.15", - "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", - "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" + "tslib": "2.8.1" }, "engines": { - "node": "4.x || >=6.0.0" + "node": ">=22.0.0" } }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, "node_modules/@supabase/postgrest-js": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.3.tgz", - "integrity": "sha512-HI6dsbW68AKlOPofUjDTaosiDBCtW4XAm0D18pPwxoW3zKOE2Ru13Z69Wuys9fd6iTpfDViNco5sgrtnP0666A==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.111.0.tgz", + "integrity": "sha512-pcqeDsnWP0lx9GawduYxNZJHeuTm53O7L0SC8RF8tniV3GWIPY6me6OTdnwzdwNUmNy1dzUVtSyIfE6+OflzPQ==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@supabase/realtime-js": { - "version": "2.10.7", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.7.tgz", - "integrity": "sha512-OLI0hiSAqQSqRpGMTUwoIWo51eUivSYlaNBgxsXZE7PSoWh12wPRdVt0psUMaUzEonSB85K21wGc7W5jHnT6uA==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.111.0.tgz", + "integrity": "sha512-6oRf/vZyRwg8f8GbFSJkrD2w4HAu/yTvyMViHXHS+H5hNJzdXCrUR7cP5oW7daT3YlRnzRPY9LcGSJKZAmfMSg==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14", - "@types/phoenix": "^1.5.4", - "@types/ws": "^8.5.10", - "ws": "^8.14.2" + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@supabase/ssr": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz", - "integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==", + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.4.tgz", + "integrity": "sha512-xHzcgI8cC1TpBKSwJcR5Yd8CCwfIq0SBc5yb4yz/YFw5tbCrEQ0QT3a+2jymCxHgQWLfzwN93HZ6eRbcoMkOlA==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.7.0" + "cookie": "^1.0.2" }, "peerDependencies": { - "@supabase/supabase-js": "^2.43.4" + "@supabase/supabase-js": "^2.111.0" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@supabase/storage-js": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", - "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.111.0.tgz", + "integrity": "sha512-UEViNmTzVOxE8dqUA81wls+n9xgmlvSFfhfwo6QxrO4kQOytCYyw3ciYFoi4XoD4Jl95NJ3jnndHN5iIudWzqw==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@supabase/supabase-js": { - "version": "2.46.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.46.1.tgz", - "integrity": "sha512-HiBpd8stf7M6+tlr+/82L8b2QmCjAD8ex9YdSAKU+whB/SHXXJdus1dGlqiH9Umy9ePUuxaYmVkGd9BcvBnNvg==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.111.0.tgz", + "integrity": "sha512-9q0/AULthQnWeiDh1vGyjoJZbSY04bu6qHcWit70pqEYn5Kv/dkCPY62Ja1123jEnJbB9Vd2pjY7Kvk/lK3peA==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.65.1", - "@supabase/functions-js": "2.4.3", - "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.16.3", - "@supabase/realtime-js": "2.10.7", - "@supabase/storage-js": "2.7.1" + "@supabase/auth-js": "2.111.0", + "@supabase/functions-js": "2.111.0", + "@supabase/postgrest-js": "2.111.0", + "@supabase/realtime-js": "2.111.0", + "@supabase/storage-js": "2.111.0" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@swc/counter": { @@ -4119,12 +4141,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4223,17 +4239,12 @@ "version": "20.17.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, - "node_modules/@types/phoenix": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.5.tgz", - "integrity": "sha512-xegpDuR+z0UqG9fwHqNoy3rI7JDlvaPh2TY47Fl80oq6g+hXT+c/LEuE43X48clZ6lOfANl5WrPur9fYO1RJ/w==", - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -4284,15 +4295,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", - "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.14.0.tgz", @@ -5442,6 +5444,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.6" } @@ -7741,6 +7744,15 @@ "node": ">= 0.8" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -12337,12 +12349,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -12568,6 +12574,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -12924,22 +12931,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13154,6 +13145,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", + "optional": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index f2f366c..3c15019 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toast": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.0", - "@supabase/ssr": "^0.5.2", - "@supabase/supabase-js": "^2.46.1", + "@supabase/ssr": "^0.12.4", + "@supabase/supabase-js": "^2.111.0", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "^5.50.0", "@trpc/client": "^11.0.0-rc.446", diff --git a/src/app/[username]/[slug]/page.tsx b/src/app/[username]/[slug]/page.tsx index 4fa0893..8aea940 100644 --- a/src/app/[username]/[slug]/page.tsx +++ b/src/app/[username]/[slug]/page.tsx @@ -1,10 +1,11 @@ -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import type { Metadata } from "next"; import { PublishedDocumentTitleSection } from "~/app/_components/published-document-title-section"; import { sanitizePublishedHtml } from "~/lib/published-html"; import { authorDisplayLabel, + getCachedPublicationRedirectByUsernameSlug, getPublicationWithAuthorByUsernameSlug, } from "~/server/db/document-publications"; @@ -14,10 +15,22 @@ type PageProps = { params: Promise<{ username: string; slug: string }>; }; +async function resolveRedirectOrContinue(username: string, slug: string) { + const redirect = await getCachedPublicationRedirectByUsernameSlug( + username, + slug + ); + if (redirect) { + permanentRedirect(`/${redirect.toOwnerUsername}/${redirect.toSlug}`); + } +} + export async function generateMetadata({ params, }: PageProps): Promise { const { username, slug } = await params; + await resolveRedirectOrContinue(username, slug); + const data = await getPublicationWithAuthorByUsernameSlug(username, slug); if (!data) { @@ -49,6 +62,8 @@ export async function generateMetadata({ export default async function PublishedDocumentPage({ params }: PageProps) { const { username, slug } = await params; + await resolveRedirectOrContinue(username, slug); + const data = await getPublicationWithAuthorByUsernameSlug(username, slug); if (!data) { diff --git a/src/app/_components/account-settings/account-settings-fields.tsx b/src/app/_components/account-settings/account-settings-fields.tsx new file mode 100644 index 0000000..f7331db --- /dev/null +++ b/src/app/_components/account-settings/account-settings-fields.tsx @@ -0,0 +1,239 @@ +"use client"; + +import * as React from "react"; + +import { Input } from "~/app/_components/input"; +import { Label } from "~/app/_components/label"; +import { PasswordInput } from "~/app/_components/password-input"; +import { MIN_PASSWORD_LENGTH } from "~/lib/account-schema"; +import { formSpacing } from "~/lib/form-spacing"; +import { cn } from "~/lib/utils"; + +import { AvatarField } from "./avatar-field"; +import type { AccountSettingsFormApi } from "./use-account-settings-form"; +import type { ChangePasswordFormApi } from "./use-change-password"; +import type { AvatarDraft } from "./use-avatar-draft"; +import type { UsernameAvailabilityStatus } from "./use-username-availability"; +import { UsernameAvailabilityFeedback } from "./username-availability-feedback"; + +/** Desktop stacks every group; mobile shows one drilled-into screen at a time. */ +export type AccountSettingsSurface = "dialog" | "drawer"; + +/** Mobile uses taller touch targets; surface styling comes from `Input`. */ +function inputClassName(surface: AccountSettingsSurface) { + return surface === "drawer" ? "h-10 rounded-lg text-base" : undefined; +} + +function SettingsField({ + id, + label, + description, + error, + status, + children, +}: { + id: string; + label: string; + description?: React.ReactNode; + error?: string; + status?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error ? ( +

{error}

+ ) : status ? ( + status + ) : description ? ( +

{description}

+ ) : null} +
+ ); +} + +export type AccountSettingsFieldsProps = { + form: AccountSettingsFormApi; + surface: AccountSettingsSurface; +}; + +export type ProfileFieldsProps = AccountSettingsFieldsProps & { + avatar: AvatarDraft; + defaultAvatarColor: string | null; + isSaving?: boolean; + usernameAvailabilityStatus?: UsernameAvailabilityStatus; +}; + +export function ProfileFields({ + form, + surface, + avatar, + defaultAvatarColor, + isSaving, + usernameAvailabilityStatus = "idle", +}: ProfileFieldsProps) { + const errors = form.formState.errors; + const className = inputClassName(surface); + + // Watched so the fallback initials track what is being typed. + const firstName = form.watch("first_name"); + const lastName = form.watch("last_name"); + + return ( +
+ +
+
+ + + + + + +
+ + ) + } + > + + +
+
+ ); +} + +export type ChangePasswordFieldsProps = { + form: ChangePasswordFormApi; + surface: AccountSettingsSurface; + reauthRequired?: boolean; + /** Shown after a successful update on surfaces that keep the form mounted. */ + successMessage?: string | null; +}; + +export function ChangePasswordFields({ + form, + surface, + reauthRequired = false, + successMessage, +}: ChangePasswordFieldsProps) { + const errors = form.formState.errors; + const className = inputClassName(surface); + const rootError = errors.root?.message; + + return ( +
+ + + + + + + + + + {reauthRequired ? ( + + + + ) : null} + {rootError ? ( +

{rootError}

+ ) : successMessage ? ( +

+ {successMessage} +

+ ) : null} +
+ ); +} diff --git a/src/app/_components/account-settings/account-settings.tsx b/src/app/_components/account-settings/account-settings.tsx new file mode 100644 index 0000000..020f343 --- /dev/null +++ b/src/app/_components/account-settings/account-settings.tsx @@ -0,0 +1,586 @@ +"use client"; + +import * as React from "react"; +import { AtSign, ChevronRight, Lock, User } from "lucide-react"; + +import { Button } from "~/app/_components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "~/app/_components/dialog"; +import { + MobileActionButtonRow, + MobileActionGroup, +} from "~/app/_components/mobile-action-rows"; +import { + MobileDrawerNavHeader, + MobileDrawerScreenHeader, + MobileDrawerViewStack, + MobileMenuDrawer, + useMobileDrawerStage, +} from "~/app/_components/mobile-drawer"; +import { PanelHeader } from "~/app/_components/panel-header"; +import { SaveFeedbackLabel } from "~/app/_components/save-feedback-label"; +import { Skeleton } from "~/app/_components/skeleton"; +import { + useAccountSettingsStore, + type AccountProfileField, + type AccountSettingsView, +} from "~/hooks/use-account-settings"; +import { useIsMobile } from "~/hooks/use-mobile"; +import { useUserProfile } from "~/hooks/use-user-profile"; +import { formSpacing } from "~/lib/form-spacing"; +import { cn } from "~/lib/utils"; + +import { ProfileFields } from "./account-settings-fields"; +import { AvatarField } from "./avatar-field"; +import { + ChangePasswordSection, + MobileChangePassword, +} from "./change-password-form"; +import { MobileProfileFieldEdit } from "./mobile-profile-field-edit"; +import { + useAccountSettingsForm, + type AccountSettingsProfile, +} from "./use-account-settings-form"; +import { useChangePassword } from "./use-change-password"; + +/** Field editors and password open the keyboard; the profile list does not. */ +const KEYBOARD_VIEWS: readonly AccountSettingsView[] = [ + "first_name", + "last_name", + "username", + "password", +]; + +const PROFILE_FIELDS: readonly AccountProfileField[] = [ + "first_name", + "last_name", + "username", +]; + +function isProfileField( + view: AccountSettingsView, +): view is AccountProfileField { + return (PROFILE_FIELDS as readonly string[]).includes(view); +} + +type DialogSectionId = "profile" | "password"; + +const DIALOG_SECTIONS: readonly { + id: DialogSectionId; + label: string; + icon: typeof User; +}[] = [ + { id: "profile", label: "Profile", icon: User }, + { id: "password", label: "Password", icon: Lock }, +]; + +const PROFILE_FORM_ID = "account-settings-profile-form"; +const PASSWORD_FORM_ID = "account-settings-password-form"; + +/** Fixed shell — header/nav/footer stay put; only the content pane scrolls. */ +const DIALOG_SHELL_CLASS = + "flex h-[min(85vh,32.1rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden border-0 bg-white p-0 shadow-2xl dark:bg-sidebar"; + +const DIALOG_HEADER_CLASS = "shrink-0 py-5 pl-6 pr-12"; + +const DIALOG_FOOTER_CLASS = + "flex shrink-0 items-center justify-end gap-2 px-6 py-4"; + +function LoadingFields({ rows }: { rows: number }) { + return ( +
+ {Array.from({ length: rows }, (_, index) => ( +
+ + +
+ ))} +
+ ); +} + +function UnavailableBody() { + return ( +

+ We couldn't load your account details. Try again in a moment. +

+ ); +} + +function DialogSection({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+

{description}

+
+ {children} +
+ ); +} + +function AccountSettingsDialogNav({ + section, + onSectionChange, +}: { + section: DialogSectionId; + onSectionChange: (section: DialogSectionId) => void; +}) { + return ( + + ); +} + +function AccountSettingsDialogBody({ + profile, + onSavingChange, +}: { + profile: AccountSettingsProfile; + onSavingChange?: (saving: boolean) => void; +}) { + const [section, setSection] = React.useState("profile"); + + const profileForm = useAccountSettingsForm({ profile }); + const passwordForm = useChangePassword(); + const isSaving = profileForm.isSaving || passwordForm.isSaving; + + React.useEffect(() => { + onSavingChange?.(isSaving); + return () => onSavingChange?.(false); + }, [isSaving, onSavingChange]); + + const active = + section === "profile" + ? { + formId: PROFILE_FORM_ID, + saveDisabled: profileForm.saveDisabled, + isBusy: profileForm.isBusy, + saveState: profileForm.saveState, + idleLabel: "Save Profile", + savingLabel: "Saving…", + savedLabel: "Saved", + } + : { + formId: PASSWORD_FORM_ID, + saveDisabled: passwordForm.saveDisabled, + isBusy: passwordForm.isBusy, + saveState: passwordForm.saveState, + idleLabel: "Update Password", + savingLabel: "Updating…", + savedLabel: "Updated", + }; + + return ( + <> + + +
+ +
+ {/* Keep both mounted so in-progress edits survive section switches. */} + + +
+
+ +
+ +
+ + ); +} + +function AccountSettingsDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { data: profile, isLoading } = useUserProfile(); + const [isSaving, setIsSaving] = React.useState(false); + + const handleOpenChange = React.useCallback( + (next: boolean) => { + if (!next && isSaving) return; + onOpenChange(next); + }, + [isSaving, onOpenChange], + ); + + const preventDismissWhileSaving = React.useCallback( + (event: Event) => { + if (isSaving) event.preventDefault(); + }, + [isSaving], + ); + + return ( + + + + Manage your profile and password. + + + {profile ? ( + + ) : ( + <> + +
+ {isLoading ? : } +
+ + )} +
+
+ ); +} + +function RowTrailing({ value }: { value?: string }) { + return ( + + {value ? ( + {value} + ) : null} + + + ); +} + +function AccountSettingsDrawerBody({ + profile, + onSavingChange, +}: { + profile: AccountSettingsProfile; + onSavingChange?: (saving: boolean) => void; +}) { + const view = useAccountSettingsStore((state) => state.view); + const setView = useAccountSettingsStore((state) => state.setView); + + const stage = useMobileDrawerStage({ + view, + setView, + mainView: "main", + keyboardView: KEYBOARD_VIEWS, + measureDeps: [profile], + }); + + const { avatar, submit, isSaving, isBusy, saveDisabled, saveState } = + useAccountSettingsForm({ + profile, + }); + const [childSaving, setChildSaving] = React.useState(false); + const dismissLocked = isSaving || childSaving; + + React.useEffect(() => { + onSavingChange?.(dismissLocked); + return () => onSavingChange?.(false); + }, [dismissLocked, onSavingChange]); + + const openSubView = React.useCallback( + (next: AccountSettingsView) => { + stage.measureMainStage(); + stage.goToView(next, 1); + }, + [stage], + ); + + const returnToProfile = React.useCallback(() => { + stage.returnToView("profile"); + }, [stage]); + + const returnToMain = React.useCallback(() => { + stage.returnToMainView(); + }, [stage]); + + const fullName = [profile.first_name, profile.last_name] + .filter((part): part is string => Boolean(part?.trim())) + .join(" "); + + const renderMainView = () => ( +
+ +
+ + } + onClick={() => openSubView("profile")} + /> + } + onClick={() => openSubView("password")} + /> + +
+
+ ); + + const renderProfileView = () => ( +
{ + event.preventDefault(); + void submit(); + }} + > + } + disabled={isSaving} + // Save is only for avatar changes on this screen. + doneDisabled={saveDisabled || isBusy || !avatar.isDirty} + doneClassName={isBusy ? "disabled:opacity-100" : undefined} + onBack={stage.returnToMainView} + onDone={() => { + void submit(); + }} + /> +
+ + + } + onClick={() => openSubView("first_name")} + /> + } + onClick={() => openSubView("last_name")} + /> + + } + onClick={() => openSubView("username")} + /> + +
+ + ); + + return ( + { + if (currentView === "profile") { + return renderProfileView(); + } + + if (isProfileField(currentView)) { + return ( + + ); + } + + if (currentView === "password") { + return ( + + ); + } + + return renderMainView(); + }} + /> + ); +} + +function AccountSettingsDrawer({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { data: profile, isLoading } = useUserProfile(); + const [isSaving, setIsSaving] = React.useState(false); + + const handleOpenChange = React.useCallback( + (next: boolean) => { + if (!next && isSaving) return; + onOpenChange(next); + }, + [isSaving, onOpenChange], + ); + + return ( + + {profile ? ( + + ) : ( +
+ + {isLoading ? : } +
+ )} +
+ ); +} + +/** Account settings: a centered modal on desktop, a bottom drawer on mobile. */ +export function AccountSettings() { + const isOpen = useAccountSettingsStore((state) => state.isOpen); + const setOpen = useAccountSettingsStore((state) => state.setOpen); + const isMobile = useIsMobile(); + + if (isMobile) { + return ; + } + + return ; +} diff --git a/src/app/_components/account-settings/avatar-field.tsx b/src/app/_components/account-settings/avatar-field.tsx new file mode 100644 index 0000000..47b38d3 --- /dev/null +++ b/src/app/_components/account-settings/avatar-field.tsx @@ -0,0 +1,91 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "~/app/_components/button"; +import { UserAvatar } from "~/app/_components/user-avatar"; +import { AVATAR_ACCEPT_ATTRIBUTE, AVATAR_MAX_SIZE_LABEL } from "~/lib/avatar-schema"; +import { formSpacing } from "~/lib/form-spacing"; +import { cn } from "~/lib/utils"; + +import type { AvatarDraft } from "./use-avatar-draft"; + +export function AvatarField({ + draft, + firstName, + lastName, + defaultAvatarColor, + disabled, +}: { + draft: AvatarDraft; + firstName: string; + lastName: string; + defaultAvatarColor: string | null; + disabled?: boolean; +}) { + const inputRef = React.useRef(null); + + const handleChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) draft.select(file); + // Clear the value so re-picking the same file still fires a change event. + event.target.value = ""; + }; + + return ( +
+
+ + +
+ + {draft.hasImage ? ( + + ) : null} +
+ + +
+ + {draft.error ? ( +

+ {draft.error} +

+ ) : ( +

+ PNG, JPEG, WebP, or GIF up to {AVATAR_MAX_SIZE_LABEL}. +

+ )} +
+ ); +} diff --git a/src/app/_components/account-settings/change-password-form.tsx b/src/app/_components/account-settings/change-password-form.tsx new file mode 100644 index 0000000..adb67f3 --- /dev/null +++ b/src/app/_components/account-settings/change-password-form.tsx @@ -0,0 +1,92 @@ +"use client"; + +import type { FormEventHandler } from "react"; +import * as React from "react"; + +import { + MobileDrawerFieldView, + useMobileDrawerLeave, +} from "~/app/_components/mobile-drawer"; +import { SaveFeedbackLabel } from "~/app/_components/save-feedback-label"; + +import { ChangePasswordFields } from "./account-settings-fields"; +import { + useChangePassword, + type ChangePasswordFormApi, +} from "./use-change-password"; + +/** Desktop password fields — submit is owned by the dialog footer. */ +export function ChangePasswordSection({ + form, + formId, + onSubmit, + reauthRequired, + successMessage, +}: { + form: ChangePasswordFormApi; + formId: string; + onSubmit: FormEventHandler; + reauthRequired: boolean; + successMessage?: string | null; +}) { + return ( +
+ + + ); +} + +/** Mobile drawer password screen — isolated from the profile form. */ +export function MobileChangePassword({ + onBack, + onSaved, + onSavingChange, +}: { + onBack: () => void; + onSaved: () => void; + onSavingChange?: (saving: boolean) => void; +}) { + const leave = useMobileDrawerLeave(); + const { + form, + submit, + isSaving, + isBusy, + saveDisabled, + saveState, + reauthRequired, + } = useChangePassword({ + onSaved: () => leave(onSaved), + }); + + React.useEffect(() => { + onSavingChange?.(isSaving); + return () => onSavingChange?.(false); + }, [isSaving, onSavingChange]); + + return ( + } + disabled={isSaving} + doneDisabled={saveDisabled} + doneClassName={isBusy ? "disabled:opacity-100" : undefined} + dismissKeyboardOnDone={false} + onBack={onBack} + onDone={() => { + void submit(); + }} + > + + + ); +} diff --git a/src/app/_components/account-settings/index.ts b/src/app/_components/account-settings/index.ts new file mode 100644 index 0000000..54bf89c --- /dev/null +++ b/src/app/_components/account-settings/index.ts @@ -0,0 +1,6 @@ +export { AccountSettings } from "./account-settings"; +export { + ChangePasswordFields, + ProfileFields, + type AccountSettingsSurface, +} from "./account-settings-fields"; diff --git a/src/app/_components/account-settings/mobile-profile-field-edit.tsx b/src/app/_components/account-settings/mobile-profile-field-edit.tsx new file mode 100644 index 0000000..e4aaf49 --- /dev/null +++ b/src/app/_components/account-settings/mobile-profile-field-edit.tsx @@ -0,0 +1,204 @@ +"use client"; + +import * as React from "react"; + +import { Input } from "~/app/_components/input"; +import { + MOBILE_DRAWER_FIELD_INPUT_CLASS, + MobileDrawerFieldView, + useMobileDrawerLeave, +} from "~/app/_components/mobile-drawer"; +import { SaveFeedbackLabel } from "~/app/_components/save-feedback-label"; +import { + SAVE_FEEDBACK_SETTLE_MS, + useSaveFeedback, +} from "~/hooks/use-save-feedback"; +import { useToast } from "~/hooks/use-toast"; +import type { AccountProfileField } from "~/hooks/use-account-settings"; +import { usernameSchema } from "~/lib/account-schema"; +import { api } from "~/trpc/react"; + +import type { AccountSettingsProfile } from "./use-account-settings-form"; +import { useUsernameAvailability } from "./use-username-availability"; +import { UsernameAvailabilityFeedback } from "./username-availability-feedback"; + +const FIELD_COPY: Record< + AccountProfileField, + { title: string; helperText: string; autoComplete: string } +> = { + first_name: { + title: "First name", + helperText: "Shown on your profile and published documents.", + autoComplete: "given-name", + }, + last_name: { + title: "Last name", + helperText: "Shown on your profile and published documents.", + autoComplete: "family-name", + }, + username: { + title: "Username", + helperText: "Optional. Published documents live at /username/document.", + autoComplete: "username", + }, +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "An unexpected error occurred"; +} + +function profileFieldValue( + profile: AccountSettingsProfile, + field: AccountProfileField, +) { + return profile[field] ?? ""; +} + +/** Single-field profile editor for the mobile account drawer. */ +export function MobileProfileFieldEdit({ + field, + profile, + onBack, + onSaved, + onSavingChange, +}: { + field: AccountProfileField; + profile: AccountSettingsProfile; + onBack: () => void; + onSaved: () => void; + onSavingChange?: (saving: boolean) => void; +}) { + const copy = FIELD_COPY[field]; + const initialValue = profileFieldValue(profile, field); + const [draft, setDraft] = React.useState(initialValue); + const { toast } = useToast(); + const utils = api.useUtils(); + const feedback = useSaveFeedback(); + const leave = useMobileDrawerLeave(); + const updateProfile = api.user.updateProfile.useMutation(); + + const availability = useUsernameAvailability( + field === "username" ? draft : "", + profile.username, + ); + + React.useEffect(() => { + setDraft(initialValue); + }, [initialValue, field]); + + const trimmed = draft.trim(); + const dirty = trimmed !== initialValue.trim(); + const usernameParse = + field === "username" && trimmed.length > 0 + ? usernameSchema.safeParse(trimmed) + : null; + const usernameInvalid = usernameParse !== null && !usernameParse.success; + const isBusy = + feedback.inFlight || + feedback.state === "saving" || + feedback.state === "saved"; + const isSaving = feedback.inFlight || feedback.state === "saving"; + + React.useEffect(() => { + onSavingChange?.(isSaving); + return () => onSavingChange?.(false); + }, [isSaving, onSavingChange]); + + const saveDisabled = + !dirty || + isBusy || + usernameInvalid || + (field === "username" && + (availability.isTaken || availability.isChecking)); + + const save = async () => { + if ( + !dirty || + usernameInvalid || + availability.isTaken || + availability.isChecking || + feedback.inFlight || + feedback.state === "saving" + ) { + return; + } + + feedback.start(); + + try { + await updateProfile.mutateAsync({ + first_name: + field === "first_name" ? trimmed : (profile.first_name ?? ""), + last_name: field === "last_name" ? trimmed : (profile.last_name ?? ""), + username: field === "username" ? trimmed : (profile.username ?? ""), + }); + await utils.user.getCurrentUserProfile.invalidate(); + await utils.document.getPublicationByDocumentId.invalidate(); + await utils.document.getPublicationOwnerPathSegment.invalidate(); + await feedback.settle("saved"); + feedback.runAfterResult(() => { + leave(onSaved); + }, SAVE_FEEDBACK_SETTLE_MS); + } catch (error) { + toast({ + variant: "destructive", + title: "Couldn't update profile", + description: errorMessage(error), + }); + await feedback.settle("failed"); + } + }; + + const schemaError = usernameInvalid + ? usernameParse.error.issues[0]?.message + : undefined; + + const inputId = `account-settings-${field}`; + const helperTextId = `${inputId}-helper`; + const descriptionId = `${inputId}-description`; + const showAvailability = + field === "username" && availability.status !== "idle"; + const description = schemaError ? ( +

{schemaError}

+ ) : showAvailability ? ( + + ) : undefined; + const describedBy = [ + description ? descriptionId : null, + !description ? helperTextId : null, + ] + .filter(Boolean) + .join(" "); + + return ( + } + disabled={feedback.inFlight || feedback.state === "saving"} + doneDisabled={saveDisabled} + doneClassName={isBusy ? "disabled:opacity-100" : undefined} + dismissKeyboardOnDone={false} + onBack={onBack} + onDone={() => { + void save(); + }} + > + setDraft(event.target.value)} + className={MOBILE_DRAWER_FIELD_INPUT_CLASS} + aria-invalid={Boolean(schemaError) || availability.isTaken} + aria-describedby={describedBy || undefined} + /> + + ); +} diff --git a/src/app/_components/account-settings/use-account-settings-form.ts b/src/app/_components/account-settings/use-account-settings-form.ts new file mode 100644 index 0000000..4cec071 --- /dev/null +++ b/src/app/_components/account-settings/use-account-settings-form.ts @@ -0,0 +1,178 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { + SAVE_FEEDBACK_SETTLE_MS, + useSaveFeedback, +} from "~/hooks/use-save-feedback"; +import { useToast } from "~/hooks/use-toast"; +import { + accountSettingsSchema, + type AccountSettingsValues, +} from "~/lib/account-schema"; +import { api } from "~/trpc/react"; + +import { useAvatarDraft } from "./use-avatar-draft"; +import { useUsernameAvailability } from "./use-username-availability"; + +export type AccountSettingsProfile = { + first_name: string | null; + last_name: string | null; + username: string | null; + avatar_url: string | null; + default_avatar_background_color: string | null; +}; + +function toFormValues( + profile: AccountSettingsProfile, +): AccountSettingsValues { + return { + first_name: profile.first_name ?? "", + last_name: profile.last_name ?? "", + username: profile.username ?? "", + }; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "An unexpected error occurred"; +} + +/** + * Profile + avatar form for account settings. Password changes use + * `useChangePassword` and never pass through this hook or tRPC. + */ +export function useAccountSettingsForm({ + profile, + onSaved, +}: { + profile: AccountSettingsProfile; + onSaved?: () => void; +}) { + const { toast } = useToast(); + const utils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(accountSettingsSchema), + defaultValues: toFormValues(profile), + }); + + const avatar = useAvatarDraft(profile.avatar_url); + const feedback = useSaveFeedback(); + const watchedUsername = form.watch("username"); + const usernameAvailability = useUsernameAvailability( + watchedUsername, + profile.username, + ); + + const updateProfile = api.user.updateProfile.useMutation(); + const updateAvatar = api.user.updateAvatar.useMutation(); + + const submit = form.handleSubmit(async (values) => { + if (usernameAvailability.isTaken || usernameAvailability.isChecking) return; + + const defaults = form.formState.defaultValues; + const profileChanged = + values.first_name !== (defaults?.first_name ?? "") || + values.last_name !== (defaults?.last_name ?? "") || + values.username !== (defaults?.username ?? ""); + const avatarChanged = avatar.isDirty; + + if (!profileChanged && !avatarChanged) return; + + feedback.start(); + + let savedProfile = { + first_name: values.first_name, + last_name: values.last_name, + username: values.username, + }; + let avatarSaved = false; + + if (avatarChanged) { + try { + const result = await avatar.commit(); + if (result.changed) { + await updateAvatar.mutateAsync({ path: result.path }); + avatarSaved = true; + } + } catch (error) { + toast({ + variant: "destructive", + title: "Couldn't update photo", + description: errorMessage(error), + }); + await feedback.settle("failed"); + return; + } + } + + if (profileChanged) { + try { + const updated = await updateProfile.mutateAsync(savedProfile); + savedProfile = { + first_name: updated.first_name ?? "", + last_name: updated.last_name ?? "", + username: updated.username ?? "", + }; + } catch (error) { + toast({ + variant: "destructive", + title: "Couldn't update profile", + description: errorMessage(error), + }); + if (avatarSaved) { + await utils.user.getCurrentUserProfile.invalidate(); + avatar.reset(); + } + await feedback.settle("failed"); + return; + } + } + + if (profileChanged || avatarSaved) { + await utils.user.getCurrentUserProfile.invalidate(); + if (profileChanged) { + await utils.document.getPublicationByDocumentId.invalidate(); + await utils.document.getPublicationOwnerPathSegment.invalidate(); + } + } + + avatar.reset(); + form.reset(savedProfile); + + await feedback.settle("saved"); + + if (onSaved) feedback.runAfterResult(onSaved, SAVE_FEEDBACK_SETTLE_MS); + }); + + const dirty = form.formState.isDirty || avatar.isDirty; + const isSaving = + feedback.inFlight || + feedback.state === "saving" || + form.formState.isSubmitting || + updateProfile.isPending || + updateAvatar.isPending; + const isBusy = isSaving || feedback.state === "saved"; + const isUsernameTaken = usernameAvailability.isTaken; + const isUsernameUnresolved = + usernameAvailability.isChecking || isUsernameTaken; + + return { + form, + avatar, + submit, + isSaving, + isBusy, + isUsernameTaken, + usernameAvailabilityStatus: usernameAvailability.status, + saveState: feedback.state, + canSave: dirty && !isBusy && !isUsernameUnresolved, + saveDisabled: (!dirty && !isBusy) || isUsernameUnresolved, + }; +} + +export type AccountSettingsFormApi = ReturnType< + typeof useAccountSettingsForm +>["form"]; diff --git a/src/app/_components/account-settings/use-avatar-draft.ts b/src/app/_components/account-settings/use-avatar-draft.ts new file mode 100644 index 0000000..b778c01 --- /dev/null +++ b/src/app/_components/account-settings/use-avatar-draft.ts @@ -0,0 +1,101 @@ +"use client"; + +import * as React from "react"; + +import { + AVATAR_BUCKET, + buildAvatarPath, + validateAvatarFile, +} from "~/lib/avatar-schema"; +import { createClient } from "~/utils/supabase/client"; + +type AvatarCommit = + | { changed: false } + | { changed: true; path: string | null }; + +/** + * Holds a pending avatar choice locally so it commits with the rest of the + * account form's Save and is discarded by Cancel. Nothing reaches storage until + * `commit` runs. + */ +export function useAvatarDraft(currentUrl: string | null) { + const [file, setFile] = React.useState(null); + const [isCleared, setIsCleared] = React.useState(false); + const [error, setError] = React.useState(null); + const [previewUrl, setPreviewUrl] = React.useState(null); + + React.useEffect(() => { + if (!file) { + setPreviewUrl(null); + return; + } + + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [file]); + + const select = React.useCallback((nextFile: File) => { + const validationError = validateAvatarFile(nextFile); + if (validationError) { + setError(validationError); + return; + } + + setError(null); + setIsCleared(false); + setFile(nextFile); + }, []); + + const clear = React.useCallback(() => { + setError(null); + setFile(null); + setPreviewUrl(null); + setIsCleared(true); + }, []); + + const reset = React.useCallback(() => { + setError(null); + setFile(null); + setIsCleared(false); + }, []); + + /** Uploads the staged file, if any, and reports the path to persist. */ + const commit = React.useCallback(async (): Promise => { + if (file) { + const supabase = createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) throw new Error("Your session expired. Sign in again."); + + const path = buildAvatarPath(user.id, file); + const { error: uploadError } = await supabase.storage + .from(AVATAR_BUCKET) + .upload(path, file, { contentType: file.type }); + + if (uploadError) throw new Error(uploadError.message); + + return { changed: true, path }; + } + + if (isCleared && currentUrl) return { changed: true, path: null }; + + return { changed: false }; + }, [currentUrl, file, isCleared]); + + return { + /** Staged image if picked, else the saved one, else nothing. */ + displayUrl: previewUrl ?? (isCleared ? null : currentUrl), + hasImage: Boolean(previewUrl ?? (isCleared ? null : currentUrl)), + error, + isDirty: file !== null || (isCleared && Boolean(currentUrl)), + select, + clear, + reset, + commit, + }; +} + +export type AvatarDraft = ReturnType; diff --git a/src/app/_components/account-settings/use-change-password.ts b/src/app/_components/account-settings/use-change-password.ts new file mode 100644 index 0000000..c55a25e --- /dev/null +++ b/src/app/_components/account-settings/use-change-password.ts @@ -0,0 +1,102 @@ +"use client"; + +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { + SAVE_FEEDBACK_SETTLE_MS, + useSaveFeedback, +} from "~/hooks/use-save-feedback"; +import { + changePasswordSchema, + type ChangePasswordValues, +} from "~/lib/account-schema"; +import { changePasswordWithSupabase } from "~/lib/change-password"; + +const EMPTY_VALUES: ChangePasswordValues = { + currentPassword: "", + password: "", + confirmPassword: "", + nonce: "", +}; + +/** + * Dedicated password-change form. Talks to Supabase Auth from the browser only. + */ +export function useChangePassword({ onSaved }: { onSaved?: () => void } = {}) { + const feedback = useSaveFeedback(); + const [reauthRequired, setReauthRequired] = React.useState(false); + + const form = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: EMPTY_VALUES, + }); + + const submit = form.handleSubmit(async (values) => { + if (reauthRequired && !values.nonce?.trim()) { + form.setError("nonce", { + message: "Verification code is required", + }); + return; + } + + feedback.start(); + + const trimmedNonce = values.nonce?.trim(); + const result = await changePasswordWithSupabase({ + currentPassword: values.currentPassword, + password: values.password, + nonce: reauthRequired && trimmedNonce ? trimmedNonce : undefined, + }); + + if (result.status === "reauthentication_required") { + setReauthRequired(true); + form.setValue("nonce", "", { shouldDirty: true }); + form.clearErrors(); + form.setError("nonce", { + message: "Enter the verification code we sent to your email", + }); + await feedback.settle("failed"); + return; + } + + if (result.status === "error") { + if (result.field) { + form.setError(result.field, { message: result.message }); + } else { + form.setError("root", { message: result.message }); + } + await feedback.settle("failed"); + return; + } + + setReauthRequired(false); + form.reset(EMPTY_VALUES); + await feedback.settle("saved"); + + if (onSaved) feedback.runAfterResult(onSaved, SAVE_FEEDBACK_SETTLE_MS); + }); + + const isSaving = + feedback.inFlight || + feedback.state === "saving" || + form.formState.isSubmitting; + const isBusy = isSaving || feedback.state === "saved"; + const dirty = form.formState.isDirty; + + return { + form, + submit, + isSaving, + isBusy, + saveState: feedback.state, + reauthRequired, + canSave: dirty && !isBusy, + saveDisabled: !dirty || isBusy, + }; +} + +export type ChangePasswordFormApi = ReturnType< + typeof useChangePassword +>["form"]; diff --git a/src/app/_components/account-settings/use-username-availability.ts b/src/app/_components/account-settings/use-username-availability.ts new file mode 100644 index 0000000..7ff3f24 --- /dev/null +++ b/src/app/_components/account-settings/use-username-availability.ts @@ -0,0 +1,73 @@ +"use client"; + +import * as React from "react"; + +import { usernameSchema } from "~/lib/account-schema"; +import { api } from "~/trpc/react"; + +export type UsernameAvailabilityStatus = + | "idle" + | "checking" + | "available" + | "taken"; + +const DEBOUNCE_MS = 400; + +/** + * Debounced username availability against the server. Skips the network when the + * value is empty, invalid, or unchanged from the caller's current username. + */ +export function useUsernameAvailability( + username: string, + currentUsername: string | null, +) { + const trimmed = username.trim(); + const current = (currentUsername ?? "").trim(); + const isOwnUsername = + trimmed.length > 0 && + trimmed.toLowerCase() === current.toLowerCase(); + const isValid = + usernameSchema.safeParse(trimmed).success && trimmed.length > 0; + const shouldCheck = isValid && !isOwnUsername; + + const [debouncedUsername, setDebouncedUsername] = React.useState(trimmed); + + React.useEffect(() => { + const id = window.setTimeout(() => { + setDebouncedUsername(trimmed); + }, DEBOUNCE_MS); + return () => window.clearTimeout(id); + }, [trimmed]); + + const debouncedMatches = debouncedUsername === trimmed; + const queryEnabled = + shouldCheck && + debouncedMatches && + debouncedUsername.length > 0 && + debouncedUsername.toLowerCase() !== current.toLowerCase(); + + const query = api.user.checkUsernameAvailability.useQuery( + { username: debouncedUsername }, + { + enabled: queryEnabled, + staleTime: 30_000, + }, + ); + + const isDebouncing = shouldCheck && !debouncedMatches; + + let status: UsernameAvailabilityStatus = "idle"; + if (shouldCheck) { + if (isDebouncing || query.isFetching || (queryEnabled && query.isPending)) { + status = "checking"; + } else if (query.isSuccess) { + status = query.data.available ? "available" : "taken"; + } + } + + return { + status, + isTaken: status === "taken", + isChecking: status === "checking", + }; +} diff --git a/src/app/_components/account-settings/username-availability-feedback.tsx b/src/app/_components/account-settings/username-availability-feedback.tsx new file mode 100644 index 0000000..f6dfae1 --- /dev/null +++ b/src/app/_components/account-settings/username-availability-feedback.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Check, Loader2 } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +import type { UsernameAvailabilityStatus } from "./use-username-availability"; + +const FADE_TRANSITION = { + duration: 0.18, + ease: [0.22, 1, 0.36, 1] as const, +}; + +/** + * Inline username availability feedback. Fade-only transitions match the save + * button's timing, without the slide. + */ +export function UsernameAvailabilityFeedback({ + status, + className, + id, +}: { + status: UsernameAvailabilityStatus; + className?: string; + id?: string; +}) { + if (status === "idle") return null; + + return ( + + + + {status === "checking" ? ( + <> + + Checking availability + + ) : status === "available" ? ( + <> + + Username is available + + ) : ( + "Username is taken" + )} + + + + ); +} diff --git a/src/app/_components/dialog.tsx b/src/app/_components/dialog.tsx index ffd313f..7c0aa36 100644 --- a/src/app/_components/dialog.tsx +++ b/src/app/_components/dialog.tsx @@ -29,8 +29,11 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** Set false when the panel supplies its own dismiss control. */ + showCloseButton?: boolean + } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( {children} - - - Close - + {showCloseButton ? ( + + + Close + + ) : null} )) diff --git a/src/app/_components/document-actions.tsx b/src/app/_components/document-actions.tsx index 27e6a2e..397add4 100644 --- a/src/app/_components/document-actions.tsx +++ b/src/app/_components/document-actions.tsx @@ -5,7 +5,6 @@ import { useParams } from "next/navigation"; import { api } from "~/trpc/react"; import { MoreVertical } from "lucide-react"; import { Button } from "./button"; -import { Drawer, DrawerContent, DrawerTrigger } from "./drawer"; import { DropdownMenu, DropdownMenuContent, @@ -13,8 +12,8 @@ import { } from "./dropdown-menu"; import { Skeleton } from "./skeleton"; import { - MOBILE_DRAWER_SHELL_CLASS, MobileDrawerScreenHeader, + MobileMenuDrawer, } from "~/app/_components/mobile-drawer"; import { DocumentPublishMobileDrawer, @@ -140,24 +139,21 @@ export function DocumentActions() { if (isMobile) { return ( - - {triggerButton} - - {publishCtx ? ( - - ) : ( - No changes yet} - /> - )} - - + {publishCtx ? ( + + ) : ( + No changes yet} + /> + )} + ); } diff --git a/src/app/_components/document-breadcrumb.tsx b/src/app/_components/document-breadcrumb.tsx index cb77ca0..9017b14 100644 --- a/src/app/_components/document-breadcrumb.tsx +++ b/src/app/_components/document-breadcrumb.tsx @@ -1,7 +1,6 @@ "use client"; import { useParams } from "next/navigation"; -import { flushSync } from "react-dom"; import { api } from "~/trpc/react"; import { BreadcrumbItem, Breadcrumb, BreadcrumbList } from "./breadcrumb"; import { useState } from "react"; @@ -16,11 +15,7 @@ import { PopoverTrigger, } from "~/app/_components/popover"; import { Input } from "~/app/_components/input"; -import { - applyMobileDrawerKeyboardInset, - focusMobileDrawerInput, - MobileFormDrawer, -} from "~/app/_components/mobile-drawer"; +import { MobileFormDrawer } from "~/app/_components/mobile-drawer"; import { useIsMobile } from "~/hooks/use-mobile"; export function DocumentBreadcrumb() { @@ -161,13 +156,7 @@ export function DocumentBreadcrumb() { const openTitleEditor = React.useCallback(() => { setEditingName(document?.document?.name ?? "Untitled"); if (isMobile) { - flushSync(() => { - setDrawerOpen(true); - }); - focusMobileDrawerInput(titleInputRef.current); - window.setTimeout(() => { - applyMobileDrawerKeyboardInset(); - }, 50); + setDrawerOpen(true); } else { setPopoverOpen(true); } diff --git a/src/app/_components/dropdown-menu.tsx b/src/app/_components/dropdown-menu.tsx index 3d24f5f..b38b10c 100644 --- a/src/app/_components/dropdown-menu.tsx +++ b/src/app/_components/dropdown-menu.tsx @@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef< svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", inset && "pl-8", className, )} diff --git a/src/app/_components/editor/document-publish-panels.tsx b/src/app/_components/editor/document-publish-panels.tsx index e12ed05..2959fbb 100644 --- a/src/app/_components/editor/document-publish-panels.tsx +++ b/src/app/_components/editor/document-publish-panels.tsx @@ -13,20 +13,19 @@ import { } from "lucide-react"; import Link from "next/link"; import { useEffect, useRef, useState, type ReactNode } from "react"; -import { flushSync } from "react-dom"; import { Button } from "~/app/_components/button"; +import { PanelHeader } from "~/app/_components/panel-header"; +import { + publishFeedbackToSaveState, + SaveFeedbackLabel, +} from "~/app/_components/save-feedback-label"; import { cn } from "~/lib/utils"; import { - applyMobileDrawerKeyboardInset, - focusMobileDrawerInput, - MobileDrawerEditBody, - MobileDrawerNavHeader, + MobileDrawerFieldView, MobileDrawerScreenHeader, MobileDrawerViewStack, - resetMobileDrawerKeyboardStyles, useMobileDrawerStage, - waitForMobileDrawerKeyboardDismiss, } from "~/app/_components/mobile-drawer"; import { @@ -77,11 +76,9 @@ function getMobilePublishActionRow( const iconClassName = publishFeedback === "publishing" ? "animate-spin" - : publishFeedback === "published" - ? "text-emerald-600" - : publishFeedback === "failed" - ? "text-destructive" - : undefined; + : publishFeedback === "failed" + ? "text-destructive" + : undefined; const disabledWhenIdle = !options.hasChangesToPublish && publishFeedback === "idle"; @@ -125,46 +122,36 @@ function MobilePublishEditUrlView({ }, []); const leaveEditUrl = (commit: boolean) => { - inputRef.current?.blur(); if (commit) { setSlugOverride(draftSlug); + onDone(); } else { setSlugOverride(snapshotRef.current); + onBack(); } - - waitForMobileDrawerKeyboardDismiss(() => { - resetMobileDrawerKeyboardStyles(); - if (commit) { - onDone(); - } else { - onBack(); - } - }); }; return ( - <> - leaveEditUrl(false)} - onDone={() => leaveEditUrl(true)} - /> - -
- {buildUrlSlugCluster("mobile", { - value: draftSlug, - onChange: setDraftSlug, - inputRef, - })} -
- {!ownerPreview ? ( -

- Add a username in Account to use your real URL path. -

- ) : null} -
- + leaveEditUrl(false)} + onDone={() => leaveEditUrl(true)} + > +
+ {buildUrlSlugCluster("mobile", { + value: draftSlug, + onChange: setDraftSlug, + inputRef, + })} +
+ {!ownerPreview ? ( +

+ Add a username in Account to use your real URL path. +

+ ) : null} +
); } @@ -366,17 +353,7 @@ export function DocumentPublishMobileDrawer({ statusRow={statusRow} onEditUrl={() => { stage.measureMainStage(); - flushSync(() => { - stage.expandStageForKeyboardView(); - stage.goToView("edit-url", 1); - }); - const input = document.getElementById("publish-slug-mobile"); - if (input instanceof HTMLInputElement) { - focusMobileDrawerInput(input); - window.setTimeout(() => { - applyMobileDrawerKeyboardInset(); - }, 50); - } + stage.goToView("edit-url", 1); }} /> ) @@ -414,11 +391,7 @@ export function DocumentPublishPopoverPanel() { return ( <> -
-

- Publish -

-
+ {showPublishedPopoverActions && pub ? (
{unpublishPending ? ( @@ -479,7 +452,9 @@ export function DocumentPublishPopoverPanel() { ) : ( )} diff --git a/src/app/_components/form.tsx b/src/app/_components/form.tsx index a12b81b..4ce05e9 100644 --- a/src/app/_components/form.tsx +++ b/src/app/_components/form.tsx @@ -13,6 +13,7 @@ import { useFormContext, } from "react-hook-form" +import { formSpacing } from "~/lib/form-spacing" import { cn } from "~/lib/utils" import { Label } from "~/app/_components/label" @@ -81,7 +82,7 @@ const FormItem = React.forwardRef< return ( -
+
) }) diff --git a/src/app/_components/input.tsx b/src/app/_components/input.tsx index 601b3d2..c0aa746 100644 --- a/src/app/_components/input.tsx +++ b/src/app/_components/input.tsx @@ -1,5 +1,6 @@ import * as React from "react" +import { formControlClassName } from "~/lib/form-control-styles" import { cn } from "~/lib/utils" const Input = React.forwardRef>( @@ -8,7 +9,9 @@ const Input = React.forwardRef>( {children} + {description ? ( +
+ {description} +
+ ) : null} {helperText ? ( -

{helperText}

+

+ {helperText} +

) : null}
); diff --git a/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx new file mode 100644 index 0000000..3e7146e --- /dev/null +++ b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { + useEffect, + useRef, + type FormEvent, + type ReactNode, +} from "react"; + +import { cn } from "~/lib/utils"; + +import { MobileDrawerEditBody } from "./mobile-drawer-edit-body"; +import { MobileDrawerNavHeader } from "./mobile-drawer-nav-header"; +import { useMobileDrawerLeave } from "./use-mobile-drawer-leave"; +import { focusMobileDrawerInput } from "./utils"; + +/** Size/touch overrides only — surface styles come from `Input` / `formControlClassName`. */ +export const MOBILE_DRAWER_FIELD_INPUT_CLASS = cn( + "h-10 w-full rounded-lg px-3 text-base", +); + +export type MobileDrawerFieldViewProps = { + title: string; + children: ReactNode; + onBack: () => void; + onDone: () => void; + helperText?: ReactNode; + helperTextId?: string; + /** Field status/error associated via `aria-describedby` (see EditBody). */ + description?: ReactNode; + descriptionId?: string; + backLabel?: string; + doneLabel?: ReactNode; + disabled?: boolean; + doneDisabled?: boolean; + doneClassName?: string; + /** Autofocus the first text input/textarea on mount. Default true. */ + autoFocus?: boolean; + /** + * When true (default), Done also waits for keyboard dismiss before `onDone`. + * Set false for async save flows that call `useMobileDrawerLeave()` after + * a successful save. + */ + dismissKeyboardOnDone?: boolean; + /** Wrap content in a `
` that submits via Done. Default true. */ + asForm?: boolean; + className?: string; + bodyClassName?: string; +}; + +/** + * Standard drill-down field screen: nav chrome, padded body, autofocus, and + * keyboard-safe Back (and optionally Done) navigation. + */ +export function MobileDrawerFieldView({ + title, + children, + onBack, + onDone, + helperText, + helperTextId, + description, + descriptionId, + backLabel = "Back", + doneLabel = "Done", + disabled = false, + doneDisabled, + doneClassName, + autoFocus = true, + dismissKeyboardOnDone = true, + asForm = true, + className, + bodyClassName, +}: MobileDrawerFieldViewProps) { + const rootRef = useRef(null); + const leave = useMobileDrawerLeave(); + + useEffect(() => { + if (!autoFocus) return; + + const frame = requestAnimationFrame(() => { + const input = rootRef.current?.querySelector("input, textarea"); + if ( + input instanceof HTMLInputElement || + input instanceof HTMLTextAreaElement + ) { + focusMobileDrawerInput(input); + } + }); + + return () => cancelAnimationFrame(frame); + }, [autoFocus]); + + const handleBack = () => { + leave(onBack); + }; + + const handleDone = () => { + if (dismissKeyboardOnDone) { + leave(onDone); + return; + } + onDone(); + }; + + const body = ( + <> + + + {children} + + + ); + + if (asForm) { + return ( + { + rootRef.current = node; + }} + className={className} + onSubmit={(event: FormEvent) => { + event.preventDefault(); + handleDone(); + }} + > + {body} + + ); + } + + return ( +
{ + rootRef.current = node; + }} + className={className} + > + {body} +
+ ); +} diff --git a/src/app/_components/mobile-drawer/mobile-drawer-nav-header.tsx b/src/app/_components/mobile-drawer/mobile-drawer-nav-header.tsx index 95bda01..9f22d96 100644 --- a/src/app/_components/mobile-drawer/mobile-drawer-nav-header.tsx +++ b/src/app/_components/mobile-drawer/mobile-drawer-nav-header.tsx @@ -1,5 +1,6 @@ "use client"; +import type * as React from "react"; import { ChevronLeft } from "lucide-react"; import { Button } from "~/app/_components/button"; @@ -14,8 +15,14 @@ export type MobileDrawerNavHeaderProps = { onDone: () => void; /** "Cancel" (no chevron) for standalone edit drawers; "Back" (with chevron) for drill-down. */ backLabel?: string; - doneLabel?: string; + /** A node so callers can pair the label with a spinner or checkmark. */ + doneLabel?: React.ReactNode; + /** Disables Back (and Done when `doneDisabled` is omitted). */ disabled?: boolean; + /** Independent Done disable — e.g. nothing to save, or username taken. */ + doneDisabled?: boolean; + /** Keeps Done undimmed during save feedback while still blocking presses. */ + doneClassName?: string; className?: string; }; @@ -27,9 +34,12 @@ export function MobileDrawerNavHeader({ backLabel = "Back", doneLabel = "Done", disabled = false, + doneDisabled, + doneClassName, className, }: MobileDrawerNavHeaderProps) { const showBackChevron = backLabel !== "Cancel"; + const isDoneDisabled = doneDisabled ?? disabled; return (
{doneLabel} diff --git a/src/app/_components/mobile-drawer/mobile-drawer-view-stack.tsx b/src/app/_components/mobile-drawer/mobile-drawer-view-stack.tsx index 4875707..a671cd7 100644 --- a/src/app/_components/mobile-drawer/mobile-drawer-view-stack.tsx +++ b/src/app/_components/mobile-drawer/mobile-drawer-view-stack.tsx @@ -17,7 +17,7 @@ export type MobileDrawerViewStackProps = { stageMinHeight?: number; stageIsMeasured: boolean; stageRef: RefObject; - getMotionRef: (view: T) => RefObject | undefined; + getMotionRef: (view: T) => Ref | undefined; renderView: (view: T) => ReactNode; className?: string; }; @@ -48,7 +48,7 @@ export function MobileDrawerViewStack({ } + ref={getMotionRef(view)} custom={direction} variants={mobileDrawerViewVariants} initial="enter" diff --git a/src/app/_components/mobile-drawer/mobile-form-drawer.tsx b/src/app/_components/mobile-drawer/mobile-form-drawer.tsx index 4991861..6336b94 100644 --- a/src/app/_components/mobile-drawer/mobile-form-drawer.tsx +++ b/src/app/_components/mobile-drawer/mobile-form-drawer.tsx @@ -5,17 +5,16 @@ import { useEffect, useRef, useState, - type CSSProperties, type MutableRefObject, } from "react"; -import { Drawer, DrawerContent, DrawerTrigger } from "~/app/_components/drawer"; import { Input } from "~/app/_components/input"; -import { cn } from "~/lib/utils"; -import { MOBILE_DRAWER_SHELL_CLASS } from "./constants"; -import { MobileDrawerEditBody } from "./mobile-drawer-edit-body"; -import { MobileDrawerNavHeader } from "./mobile-drawer-nav-header"; +import { + MOBILE_DRAWER_FIELD_INPUT_CLASS, + MobileDrawerFieldView, +} from "./mobile-drawer-field-view"; +import { MobileMenuDrawer } from "./mobile-menu-drawer"; export type MobileFormDrawerProps = { open: boolean; @@ -32,10 +31,10 @@ export type MobileFormDrawerProps = { inputRef?: MutableRefObject; }; -type DrawerKeyboardStyle = CSSProperties & { - "--mobile-keyboard-offset"?: string; -}; - +/** + * Standalone single-field mobile drawer (no view stack). Uses the same shell + * and field chrome as drill-down menus. + */ export function MobileFormDrawer({ open, onOpenChange, @@ -51,11 +50,6 @@ export function MobileFormDrawer({ inputRef, }: MobileFormDrawerProps) { const [draft, setDraft] = useState(initialValue); - const [keyboardOffset, setKeyboardOffset] = useState(0); - const [visualViewportHeight, setVisualViewportHeight] = useState< - number | null - >(null); - const snapshotRef = useRef(initialValue); const internalInputRef = useRef(null); const wasOpenRef = useRef(false); @@ -80,14 +74,6 @@ export function MobileFormDrawer({ [inputRef], ); - const focusInput = useCallback(() => { - if (disabled) return; - - requestAnimationFrame(() => { - internalInputRef.current?.focus({ preventScroll: true }); - }); - }, [disabled]); - const closeDrawer = useCallback(() => { onOpenChange(false); @@ -123,100 +109,38 @@ export function MobileFormDrawer({ [leave, onOpenChange], ); - useEffect(() => { - if (!open) return; - - focusInput(); - }, [open, focusInput]); - - useEffect(() => { - if (!open) { - setKeyboardOffset(0); - setVisualViewportHeight(null); - return; - } - - const viewport = window.visualViewport; - - if (!viewport) return; - - const updateViewport = () => { - const nextKeyboardOffset = Math.max( - 0, - window.innerHeight - viewport.height - viewport.offsetTop, - ); - - setKeyboardOffset(nextKeyboardOffset); - setVisualViewportHeight(viewport.height); - }; - - updateViewport(); - - viewport.addEventListener("resize", updateViewport); - viewport.addEventListener("scroll", updateViewport); - - return () => { - viewport.removeEventListener("resize", updateViewport); - viewport.removeEventListener("scroll", updateViewport); - }; - }, [open]); - - const drawerStyle: DrawerKeyboardStyle = { - bottom: `${keyboardOffset}px`, - maxHeight: visualViewportHeight - ? `calc(${visualViewportHeight}px - 16px)` - : "calc(100dvh - 16px)", - "--mobile-keyboard-offset": `${keyboardOffset}px`, - }; - return ( - - {trigger ? {trigger} : null} - - 0} - bottomUnderlayHeight={keyboardOffset} - style={drawerStyle} - className={cn(MOBILE_DRAWER_SHELL_CLASS)} + leave(false)} + onDone={() => leave(true)} > - leave(false)} - onDone={() => leave(true)} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + leave(true); + } + }} + className={MOBILE_DRAWER_FIELD_INPUT_CLASS} + aria-label={inputLabel ?? title} /> - - - setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - leave(true); - } - }} - className={cn( - "h-10 w-full rounded-lg border border-sidebar-border/70 bg-background/50 px-3 text-base shadow-inner", - "dark:border-white/[0.12] dark:bg-black/35", - "focus-visible:ring-1 focus-visible:ring-ring", - )} - aria-label={inputLabel ?? title} - /> - - - + + ); } diff --git a/src/app/_components/mobile-drawer/mobile-menu-drawer.tsx b/src/app/_components/mobile-drawer/mobile-menu-drawer.tsx new file mode 100644 index 0000000..5110d4c --- /dev/null +++ b/src/app/_components/mobile-drawer/mobile-menu-drawer.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { Drawer, DrawerContent, DrawerTrigger } from "~/app/_components/drawer"; +import { cn } from "~/lib/utils"; + +import { MOBILE_DRAWER_SHELL_CLASS } from "./constants"; +import { useMobileDrawerKeyboardOffset } from "./use-mobile-drawer-keyboard"; + +export type MobileMenuDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + /** Optional trigger; omit when the drawer is opened from external state. */ + trigger?: ReactNode; + className?: string; + /** + * When false, blocks swipe-to-dismiss and outside taps (Vaul `dismissible`). + * Default true. + */ + dismissible?: boolean; +}; + +/** + * Opinionated Vaul shell for drill-down mobile menus. + * + * Pins above the software keyboard via visual-viewport offset and sizes to + * content — pair with `useMobileDrawerStage` + `MobileDrawerViewStack`. + */ +export function MobileMenuDrawer({ + open, + onOpenChange, + children, + trigger, + className, + dismissible = true, +}: MobileMenuDrawerProps) { + const { keyboardOffset, drawerStyle } = useMobileDrawerKeyboardOffset(open); + + return ( + + {trigger ? {trigger} : null} + + 0} + bottomUnderlayHeight={keyboardOffset} + style={drawerStyle} + className={cn(MOBILE_DRAWER_SHELL_CLASS, className)} + > + {children} + + + ); +} diff --git a/src/app/_components/mobile-drawer/use-mobile-drawer-keyboard.ts b/src/app/_components/mobile-drawer/use-mobile-drawer-keyboard.ts new file mode 100644 index 0000000..eb43cd1 --- /dev/null +++ b/src/app/_components/mobile-drawer/use-mobile-drawer-keyboard.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState, type CSSProperties } from "react"; + +export type DrawerKeyboardStyle = CSSProperties & { + "--mobile-keyboard-offset"?: string; +}; + +/** + * Keeps a bottom drawer pinned above the software keyboard, and caps its height + * to the visual viewport so long content stays reachable while typing. + */ +export function useMobileDrawerKeyboardOffset(open: boolean) { + const [keyboardOffset, setKeyboardOffset] = useState(0); + const [visualViewportHeight, setVisualViewportHeight] = useState< + number | null + >(null); + + useEffect(() => { + if (!open) { + setKeyboardOffset(0); + setVisualViewportHeight(null); + return; + } + + const viewport = window.visualViewport; + + if (!viewport) return; + + const updateViewport = () => { + const nextKeyboardOffset = Math.max( + 0, + window.innerHeight - viewport.height - viewport.offsetTop, + ); + + setKeyboardOffset(nextKeyboardOffset); + setVisualViewportHeight(viewport.height); + }; + + updateViewport(); + + viewport.addEventListener("resize", updateViewport); + viewport.addEventListener("scroll", updateViewport); + + return () => { + viewport.removeEventListener("resize", updateViewport); + viewport.removeEventListener("scroll", updateViewport); + }; + }, [open]); + + const drawerStyle: DrawerKeyboardStyle = { + bottom: `${keyboardOffset}px`, + maxHeight: visualViewportHeight + ? `calc(${visualViewportHeight}px - 16px)` + : "calc(100dvh - 16px)", + "--mobile-keyboard-offset": `${keyboardOffset}px`, + }; + + return { keyboardOffset, drawerStyle }; +} diff --git a/src/app/_components/mobile-drawer/use-mobile-drawer-leave.ts b/src/app/_components/mobile-drawer/use-mobile-drawer-leave.ts new file mode 100644 index 0000000..f82ba78 --- /dev/null +++ b/src/app/_components/mobile-drawer/use-mobile-drawer-leave.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; + +import { + resetMobileDrawerKeyboardStyles, + waitForMobileDrawerKeyboardDismiss, +} from "./utils"; + +/** + * Blur the focused field, wait for the software keyboard to dismiss, then run + * `after`. Use for Back / Done navigation out of keyboard screens so the + * previous view doesn't resize against a shifting visual viewport. + */ +export function useMobileDrawerLeave() { + return useCallback((after: () => void) => { + const active = document.activeElement; + if (active instanceof HTMLElement) { + active.blur(); + } + + waitForMobileDrawerKeyboardDismiss(() => { + resetMobileDrawerKeyboardStyles(); + after(); + }); + }, []); +} diff --git a/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts b/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts index efb7be1..13f742a 100644 --- a/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts +++ b/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts @@ -20,10 +20,23 @@ export type UseMobileDrawerStageOptions = { view: T; setView: (view: T) => void; mainView: T; - keyboardView: T | null; - /** Minimum content height when expanding for the keyboard view. */ + /** + * View(s) that open the software keyboard. Pass a stable array reference when + * more than one sub-screen has inputs. + */ + keyboardView: T | readonly T[] | null; + /** + * Optional floor for keyboard-view stage height. Default 0 — size from + * measured content (correct for single-field drills and URL editors alike). + */ keyboardMinContentPx?: number; keyboardClearancePx?: number; + /** + * Grow the Vaul shell to the visual viewport while the keyboard is open. + * Default false — use `MobileMenuDrawer` (keyboard offset) instead. The inset + * fills the screen when combined with offset. + */ + keyboardShellInset?: boolean; /** Re-measure the main view when these change. */ measureDeps?: readonly unknown[]; }; @@ -33,16 +46,32 @@ export function useMobileDrawerStage({ setView, mainView, keyboardView, - keyboardMinContentPx = 268, + keyboardMinContentPx = 0, keyboardClearancePx = MOBILE_DRAWER_KEYBOARD_CLEARANCE_PX, + keyboardShellInset = false, measureDeps = [], }: UseMobileDrawerStageOptions) { const [direction, setDirection] = useState(1); const [stageMinHeight, setStageMinHeight] = useState(); const [mainStageHeight, setMainStageHeight] = useState(); + const [intermediateStageHeight, setIntermediateStageHeight] = + useState(); const stageRef = useRef(null); - const mainMeasureRef = useRef(null); - const keyboardMeasureRef = useRef(null); + const mainMeasureRef = useRef(null); + const intermediateMeasureRef = useRef(null); + const keyboardMeasureRef = useRef(null); + /** Prefer this height over the first post-return measure (avoids overshoot). */ + const restoredIntermediateHeightRef = useRef(null); + + const isKeyboardView = useCallback( + (candidate: T) => { + if (keyboardView == null) return false; + return typeof keyboardView === "string" + ? keyboardView === candidate + : keyboardView.includes(candidate); + }, + [keyboardView], + ); const goToView = useCallback( (next: T, nextDirection: number) => { @@ -53,7 +82,10 @@ export function useMobileDrawerStage({ ); const measureMainStage = useCallback(() => { - const node = mainMeasureRef.current ?? stageRef.current; + // Only measure the main view node. Falling back to stageRef poisons + // mainStageHeight when called from an intermediate screen (Profile), + // because the stage is already sized to that taller view. + const node = mainMeasureRef.current; if (!node) return; const height = node.getBoundingClientRect().height; if (height > 0) { @@ -65,16 +97,14 @@ export function useMobileDrawerStage({ }, [mainView, view]); const expandStageForKeyboardView = useCallback(() => { - const mainH = - mainStageHeight ?? - mainMeasureRef.current?.getBoundingClientRect().height ?? - 0; - const target = - Math.max(mainH, keyboardMinContentPx) + keyboardClearancePx; - setStageMinHeight(target); - }, [keyboardClearancePx, keyboardMinContentPx, mainStageHeight]); + // When min content is 0, skip the predictive floor and wait for measure so + // we don't collapse to clearance-only, then bounce back up. + if (keyboardMinContentPx <= 0) return; + setStageMinHeight(keyboardMinContentPx + keyboardClearancePx); + }, [keyboardClearancePx, keyboardMinContentPx]); const returnToMainView = useCallback(() => { + resetMobileDrawerKeyboardStyles(); if (mainStageHeight != null) { setStageMinHeight(mainStageHeight); } @@ -82,15 +112,108 @@ export function useMobileDrawerStage({ setView(mainView); }, [mainStageHeight, mainView, setView]); + /** + * Back from a keyboard/field screen to an intermediate list (e.g. Profile), + * restoring that list's measured height when we have it. + */ + const returnToView = useCallback( + (next: T) => { + if (next === mainView) { + returnToMainView(); + return; + } + + resetMobileDrawerKeyboardStyles(); + if (intermediateStageHeight != null) { + restoredIntermediateHeightRef.current = intermediateStageHeight; + setStageMinHeight(intermediateStageHeight); + // Only suppress inflated remount measures for a couple frames. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + restoredIntermediateHeightRef.current = null; + }); + }); + } + setDirection(-1); + setView(next); + }, + [intermediateStageHeight, mainView, returnToMainView, setView], + ); + + const readContentHeight = (node: HTMLDivElement) => { + // Sum direct children so a stretched absolute wrapper can't inflate height + // (common when remounting Profile after a shorter field editor). + let contentH = 0; + for (const child of Array.from(node.children)) { + if (child instanceof HTMLElement) { + contentH += child.offsetHeight; + } + } + return contentH > 0 ? contentH : node.offsetHeight; + }; + + const applyIntermediateHeight = useCallback((node: HTMLDivElement) => { + const height = readContentHeight(node); + if (height <= 0) return; + + const restored = restoredIntermediateHeightRef.current; + if (restored != null) { + // Ignore one-frame inflated measures after returning from a field editor. + if (height > restored + 8) { + setStageMinHeight(restored); + return; + } + restoredIntermediateHeightRef.current = null; + } + + setIntermediateStageHeight(height); + setStageMinHeight(height); + }, []); + + const applyKeyboardHeight = useCallback( + (node: HTMLDivElement) => { + const editH = readContentHeight(node); + if (editH <= 0) return; + setStageMinHeight( + Math.max(editH, keyboardMinContentPx) + keyboardClearancePx, + ); + }, + [keyboardClearancePx, keyboardMinContentPx], + ); + + const mainRefCallback = useCallback((node: HTMLDivElement | null) => { + mainMeasureRef.current = node; + }, []); + + const keyboardRefCallback = useCallback( + (node: HTMLDivElement | null) => { + keyboardMeasureRef.current = node; + if (node) applyKeyboardHeight(node); + }, + [applyKeyboardHeight], + ); + + const intermediateRefCallback = useCallback( + (node: HTMLDivElement | null) => { + intermediateMeasureRef.current = node; + if (node) applyIntermediateHeight(node); + }, + [applyIntermediateHeight], + ); + const getMotionRef = useCallback( (currentView: T) => { - if (currentView === mainView) return mainMeasureRef; - if (keyboardView != null && currentView === keyboardView) { - return keyboardMeasureRef; - } - return undefined; + if (currentView === mainView) return mainRefCallback; + if (isKeyboardView(currentView)) return keyboardRefCallback; + return intermediateRefCallback; }, - [keyboardView, mainView], + [ + intermediateRefCallback, + isKeyboardView, + keyboardRefCallback, + mainRefCallback, + mainView, + ], ); useLayoutEffect(() => { @@ -100,18 +223,42 @@ export function useMobileDrawerStage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [view, mainView, measureMainStage, ...measureDeps]); + // Keep intermediate stage height in sync while Profile (etc.) is showing — + // avatar load / content changes can grow past the first measure. + useLayoutEffect(() => { + if (view === mainView || isKeyboardView(view)) return; + const node = intermediateMeasureRef.current; + if (!node) return; + + applyIntermediateHeight(node); + + const observer = new ResizeObserver(() => { + applyIntermediateHeight(node); + }); + observer.observe(node); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, mainView, isKeyboardView, applyIntermediateHeight, ...measureDeps]); + useLayoutEffect(() => { - if (keyboardView == null || view !== keyboardView) return; - if (!keyboardMeasureRef.current) return; - const editH = keyboardMeasureRef.current.getBoundingClientRect().height; - if (editH <= 0) return; - const target = - Math.max(mainStageHeight ?? 0, editH) + keyboardClearancePx; - setStageMinHeight((prev) => Math.max(prev ?? 0, target)); - }, [view, keyboardView, mainStageHeight, keyboardClearancePx]); + if (!isKeyboardView(view)) return; + + resetMobileDrawerKeyboardStyles(); + + const node = keyboardMeasureRef.current; + if (!node) return; + + applyKeyboardHeight(node); + + const observer = new ResizeObserver(() => { + applyKeyboardHeight(node); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [view, isKeyboardView, applyKeyboardHeight]); useEffect(() => { - if (keyboardView == null || view !== keyboardView) return; + if (!isKeyboardView(view) || !keyboardShellInset) return; const viewport = window.visualViewport; if (!viewport) return; @@ -129,7 +276,7 @@ export function useMobileDrawerStage({ viewport.removeEventListener("resize", onViewportChange); cancelAnimationFrame(frame); }; - }, [keyboardView, view]); + }, [isKeyboardView, keyboardShellInset, view]); return { direction, @@ -137,12 +284,15 @@ export function useMobileDrawerStage({ stageIsMeasured: stageMinHeight !== undefined, stageRef, mainMeasureRef, + intermediateMeasureRef, keyboardMeasureRef, mainStageHeight, + intermediateStageHeight, goToView, measureMainStage, expandStageForKeyboardView, returnToMainView, + returnToView, getMotionRef, setDirection, }; diff --git a/src/app/_components/mobile-drawer/utils.ts b/src/app/_components/mobile-drawer/utils.ts index 6734a37..4cbef04 100644 --- a/src/app/_components/mobile-drawer/utils.ts +++ b/src/app/_components/mobile-drawer/utils.ts @@ -65,12 +65,16 @@ export function applyMobileDrawerKeyboardInset( } } -export function focusMobileDrawerInput(input: HTMLInputElement | null) { +export function focusMobileDrawerInput( + input: HTMLInputElement | HTMLTextAreaElement | null, +) { if (!input) return; try { input.focus({ preventScroll: true }); - const end = input.value.length; - input.setSelectionRange(end, end); + if (input instanceof HTMLInputElement) { + const end = input.value.length; + input.setSelectionRange(end, end); + } } catch { input.focus({ preventScroll: true }); } diff --git a/src/app/_components/nav-user.tsx b/src/app/_components/nav-user.tsx index af542bf..12cda44 100644 --- a/src/app/_components/nav-user.tsx +++ b/src/app/_components/nav-user.tsx @@ -32,6 +32,7 @@ import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import { createClient } from "~/utils/supabase/client"; import { Skeleton } from "~/app/_components/skeleton"; +import { useAccountSettingsStore } from "~/hooks/use-account-settings"; export function NavUser({ user, @@ -47,10 +48,22 @@ export function NavUser({ }; isLoading?: boolean; }) { - const { isMobile } = useSidebar(); + const { isMobile, setOpenMobile } = useSidebar(); const { theme, setTheme } = useTheme(); const router = useRouter(); const queryClient = useQueryClient(); + const openAccountSettings = useAccountSettingsStore((state) => state.open); + + // On mobile the nav itself is a drawer; dismiss it before opening the + // settings drawer so the two don't stack. + const handleOpenAccountSettings = () => { + openAccountSettings(); + if (isMobile) { + // Defer so the account drawer can mount before the nav sheet tears down — + // closing synchronously lets the same tap fall through to the page. + window.setTimeout(() => setOpenMobile(false), 0); + } + }; const handleSignOut = async () => { const supabase = createClient(); @@ -97,7 +110,13 @@ export function NavUser({ return ( - + {/* + * Desktop stays non-modal: a modal menu + the account Dialog corrupt + * Radix's shared body pointer-events bookkeeping. Mobile uses a Vaul + * drawer instead, and needs modal so the menu sits above the nav sheet + * and actually receives hover/clicks. + */} + */} - + Account @@ -167,7 +189,7 @@ export function NavUser({ setTheme(theme === "dark" ? "light" : "dark")} - className="focus:bg-sidebar-accent focus:text-sidebar-accent-foreground" + className="focus:bg-sidebar-accent focus:text-sidebar-accent-foreground data-[highlighted]:bg-sidebar-accent data-[highlighted]:text-sidebar-accent-foreground" > {theme === "dark" ? ( @@ -176,7 +198,10 @@ export function NavUser({ )} {theme === "dark" ? "Light" : "Dark"} mode - + Log out diff --git a/src/app/_components/panel-header.tsx b/src/app/_components/panel-header.tsx new file mode 100644 index 0000000..14252ce --- /dev/null +++ b/src/app/_components/panel-header.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { ElementType, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +const PANEL_HEADER_TITLE_CLASS = + "text-lg font-semibold leading-none tracking-tight"; + +export type PanelHeaderProps = { + title: ReactNode; + description?: ReactNode; + /** Trailing controls, aligned to the right of the title (e.g. a Save button). */ + action?: ReactNode; + /** + * Element or component to render the heading as. Pass Radix `DialogTitle` + * inside a dialog so the surface gets a proper accessible name. + */ + titleAs?: ElementType; + className?: string; +}; + +/** + * Standard header for desktop panels — popovers, modals, and full screens. + * Use this rather than hand-rolling a title so headings stay consistent. + */ +export function PanelHeader({ + title, + description, + action, + titleAs: Title = "h2", + className, +}: PanelHeaderProps) { + return ( +
+
+ {title} + {description ? ( +

{description}

+ ) : null} +
+ {action ?
{action}
: null} +
+ ); +} diff --git a/src/app/_components/password-input.tsx b/src/app/_components/password-input.tsx index 892ef8e..96edce3 100644 --- a/src/app/_components/password-input.tsx +++ b/src/app/_components/password-input.tsx @@ -8,9 +8,10 @@ import { Input } from "./input" const PasswordInput = React.forwardRef< HTMLInputElement, Omit, "type"> ->(({ className, ...props }, ref) => { +>(({ className, id, ...props }, ref) => { const [showPassword, setShowPassword] = React.useState(false) - const inputId = React.useId() + const generatedId = React.useId() + const inputId = id ?? generatedId return (
diff --git a/src/app/_components/save-feedback-label.tsx b/src/app/_components/save-feedback-label.tsx new file mode 100644 index 0000000..de641fb --- /dev/null +++ b/src/app/_components/save-feedback-label.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Check, Loader2 } from "lucide-react"; + +import type { SaveFeedbackState } from "~/hooks/use-save-feedback"; +import { cn } from "~/lib/utils"; + +const CONTENT_TRANSITION = { + duration: 0.18, + ease: [0.22, 1, 0.36, 1] as const, +}; + +/** + * Compact submission-button content: idle → saving → saved. Previous content + * fades out, then the next state fades and slides in from the top as one unit. + */ +export function SaveFeedbackLabel({ + state, + idleLabel = "Save", + savingLabel = "Saving", + savedLabel = "Saved", + failedLabel, + className, +}: { + state: SaveFeedbackState; + idleLabel?: string; + savingLabel?: string; + savedLabel?: string; + /** Defaults to the idle label — most surfaces rely on a toast for errors. */ + failedLabel?: string; + className?: string; +}) { + const label = + state === "saving" + ? savingLabel + : state === "saved" + ? savedLabel + : state === "failed" + ? (failedLabel ?? idleLabel) + : idleLabel; + + return ( + + + + {state === "saving" ? ( + + ) : state === "saved" ? ( + + ) : null} + {label} + + + + ); +} + +/** Maps publish-store feedback onto the shared submission-button states. */ +export function publishFeedbackToSaveState( + publishFeedback: "idle" | "publishing" | "published" | "failed", +): SaveFeedbackState { + if (publishFeedback === "publishing") return "saving"; + if (publishFeedback === "published") return "saved"; + if (publishFeedback === "failed") return "failed"; + return "idle"; +} diff --git a/src/app/_components/textarea.tsx b/src/app/_components/textarea.tsx index c480928..99e70e4 100644 --- a/src/app/_components/textarea.tsx +++ b/src/app/_components/textarea.tsx @@ -1,5 +1,6 @@ import * as React from "react" +import { formControlClassName } from "~/lib/form-control-styles" import { cn } from "~/lib/utils" const Textarea = React.forwardRef< @@ -9,7 +10,8 @@ const Textarea = React.forwardRef< return (