Compare commits

...

13 Commits

Author SHA1 Message Date
brianfertig f7cbcb793c Merge pull request 'Updates-and-holding-screens' (#8) from Updates-and-holding-screens into main
Reviewed-on: #8
2026-08-31 20:57:23 +00:00
Brian Fertig 4d9f412a1f Fixed add timeout on compact view. 2026-08-31 14:56:31 -06:00
Brian Fertig a4c20a8e43 Timing Fix 2026-08-31 14:39:58 -06:00
brianfertig 900af9f395 Merge pull request 'Profile' (#7) from Profile into main
Reviewed-on: #7
2026-08-30 19:29:51 +00:00
Brian Fertig a849293bd0 Add account linking and per-account theme persistence
- Profile page gains a Request Account Link form, plus sections for pending requests (create / deny / deny-and-block), confirmed linked accounts (toggle / remove), and blocked requesters (allow requests)
- Toggle signs the browser in as the linked account using a single-use 60-second switch token minted server-side after link verification, so the target's password is never needed; a new "account-switch" Credentials provider in auth.ts consumes the token atomically to prevent replay
- Theme preference now stored on the User profile row (new `theme` column) and applied from the root layout's no-FOUC script before first paint, so each account keeps its own look across toggles and devices; anonymous visitors keep the old localStorage behavior
- New Prisma models: AccountLinkRequest, AccountLink, AccountLinkBlock, AccountSwitchToken with composite uniques, self-link check constraint, and cascade deletes
2026-08-30 13:28:55 -06:00
Brian Fertig 83075c8791 Add user profile page with name and photo support
- New `/profile` page lets users set first/last name and upload a
  profile photo (resized to 256x256 JPEG server-side via sharp, stored
  as a data URL in Postgres since the container FS is ephemeral)
- Sidebar user block now shows the profile photo (or initial-letter
  fallback) and display name, and links to `/profile`
- Migration adds `firstName`, `lastName`, `avatar` columns to User and
  backfills them from the legacy `name` field; seed updated to match
- Bump Next.js server-action body limit to 11 MB so avatar uploads up
  to the 10 MB cap aren't silently rejected with a 413
- Expose Postgres port on the host in docker-compose for local tooling
2026-08-30 11:24:05 -06:00
brianfertig 25f5c4240e Merge pull request 'Add responsive mobile layout for the app shell and board' (#6) from Mobile into main
Reviewed-on: #6
2026-08-30 04:43:14 +00:00
Brian Fertig f21b12182c Add responsive mobile layout for the app shell and board
- Replace the fixed min-width horizontal-scroll shell with a stacked
  mobile layout (h-dvh) that keeps the desktop three-column layout at md+
- Add MobileTopBar with hamburger menu, brand, and theme toggle reachable
  without opening the drawer
- Convert SideNav into shared content plus a left-sheet hamburger drawer;
  close on navigation link tap, backdrop, or Escape
- Make the board swipeable on mobile: one lane per screen via snap paging,
  with a CategoryChips rail that tracks the active lane (IntersectionObserver)
  and glides to it on tap; lanes go full-width below md
- Turn the Scheduled panel into a bottom dock bar + bottom sheet on mobile
  (keeps overdue/today urgency badges), desktop aside unchanged at md+
- Compact board chrome for phones: icon-only Summary/View buttons, smaller
  title/padding, full-width AddCategoryLane as a snap page under empty state
- Allow tall dialogs to scroll within dvh so edit forms and the AI summary
  stay usable on small screens; make admin user table horizontally scrollable
- Support configurable ThemeToggle menu side/align so it opens downward in
  the mobile top bar instead of off-screen
- Add CDP-based screenshot script and reference shots for mobile/tablet/desktop
2026-08-29 22:42:33 -06:00
brianfertig a04e9a65bd Merge pull request 'More-Theme-Stuff' (#5) from More-Theme-Stuff into main
Reviewed-on: #5
2026-08-30 04:08:17 +00:00
Brian Fertig f66c7731d1 Add per-project theme assignment with scoped theming
- Extract theme constants (THEMES, THEME_CLASSES, DARK_SURFACES) into a new
  framework-free lib/themes.ts so server and client code share one source of
  truth; re-export from theme-provider for backward compatibility.
- Add nullable `theme` column to Project (Prisma migration + schema), plus
  ProjectThemeSchema validation and a setProjectTheme server action.
- Introduce a ThemeScope context in the provider: a subtree can temporarily
  override the global preference via useThemeScope().setScope(name); the
  scoped theme wins for resolvedTheme/DOM while active, then lifts on cleanup.
- Render an inline no-FOUC script (themeSwitchScript) on project pages that
  applies the theme class before first paint and leaves a data-project-theme
  marker the provider reads at hydration.
- Add ProjectThemeScope component that sets/clears the scope from live
  ProjectsContext state, so picking a new theme updates instantly without
  waiting for the server re-render.
- Add ProjectThemePicker dropdown (Default + light/dark options) rendered
  beside the project title in the kanban board header.
- Wire optimistic setProjectTheme into ProjectsContext with rollback and
  error toast on failure.
- Update use-dark-theme hooks to read resolvedTheme from context instead of
  resolving locally, so they reflect scoped themes correctly.
2026-08-29 22:07:10 -06:00
Brian Fertig fed65f86e0 Fix blueprint theme contrast for paper surfaces and sidebar buttons
- Dialogs, popovers, and dropdown menus were rendering ghost/outline
  buttons and editor text as white-on-white because they inherited the
  board-level cobalt tokens; restore card (paper) semantics locally.
- Sidebar/rail ghost button hover, focus, and expanded states washed out
  to white-on-white; retarget them to the sidebar accent pair.
2026-08-29 18:55:30 -06:00
Brian Fertig fca66959c2 Fix vaporwave glitch band clipping the progress pie badge
- Move overflow:hidden off the group card onto a dedicated .vw-glitch-clip
  overlay so the card can stay overflow-visible and the corner progress
  pie is no longer cropped by the card or its rounded corner.
- Add overflow-x-clip to CategoryLane so badges that poke past the card's
  right edge are discarded instead of spawning a stray horizontal scrollbar.
- Nudge TodoProgressPie to -right-2.5 and give the "N/N done" label pr-2.5
  so both clear each other within the lane's 10px margin without spilling.
2026-08-29 18:29:10 -06:00
brianfertig 6f7b72c882 Merge pull request 'Theme-Stuff' (#4) from Theme-Stuff into main
Reviewed-on: #4
2026-08-29 22:26:13 +00:00
65 changed files with 3568 additions and 256 deletions

136
.cdp/mobile-shots.cjs Normal file
View File

@ -0,0 +1,136 @@
/* Mobile responsive verification: login, then screenshot the app at a
phone viewport (390x844) in the key mobile states, plus a desktop
sanity shot. Uses the Playwright-cached chromium headless shell. */
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const path = require("node:path");
const CHROME = "/home/brianfertig/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome";
const PORT = 9351;
const BASE = "http://localhost:3000";
const OUT = path.join(__dirname, "..", "shots");
function httpJson(p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
let d = ""; res.on("data", (c) => (d += c));
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
});
req.on("error", reject); req.end();
});
}
(async () => {
const profile = fs.mkdtempSync("/tmp/chrome-prof-");
const proc = spawn(CHROME, ["--headless=new", "--no-sandbox", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
let targets = null;
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
if (!targets) { console.error("no targets"); proc.kill(); process.exit(1); }
const page = targets.find((t) => t.type === "page");
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const send = (method, params = {}) => new Promise((resolve) => {
const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params }));
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
});
ws._buf = [];
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
if (!/hydrated|React DevTools|HMR|404/.test(txt)) console.log("[err]", txt.slice(0, 160));
} };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function shot(name, w, h, mobile) {
await send("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: 1, mobile, hasTouch: mobile });
const s = await send("Page.captureScreenshot", { format: "png" });
fs.writeFileSync(path.join(OUT, `${name}.png`), Buffer.from(s.data, "base64"));
console.log(`saved ${name} (${w}x${h})`);
}
async function click(css) {
const res = await send("Runtime.evaluate", { expression: `(() => { const el = document.querySelector(${JSON.stringify(css)}); if (!el) return "missing"; el.click(); return "clicked"; })()`, returnByValue: true });
console.log(`click(${css}) ->`, res?.result?.value ?? res);
}
async function evalJs(expr) {
return (await send("Runtime.evaluate", { expression: expr, returnByValue: true }))?.result?.value;
}
await send("Runtime.enable"); await send("Page.enable");
await send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true, hasTouch: true });
// ---- login (phone viewport) ----
await send("Page.navigate", { url: `${BASE}/login` });
await wait(3000);
const loginRes = await evalJs(`(() => {
const setVal = (el, v) => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
setter.call(el, v);
el.dispatchEvent(new Event("input", { bubbles: true }));
};
const email = document.querySelector("#email");
const password = document.querySelector("#password");
if (!email || !password) return "inputs missing";
setVal(email, "dev@example.com");
setVal(password, "password123");
email.form.requestSubmit();
return "submitted";
})()`);
console.log("login ->", loginRes);
await wait(4000);
console.log("now at", await evalJs("location.href"));
// ---- 1. home board, mobile ----
await send("Page.navigate", { url: `${BASE}/` });
await wait(3500);
await shot("m01-home-board", 390, 844, true);
// ---- 2. nav drawer open ----
await click('button[aria-label="Open menu"]');
await wait(900);
await shot("m02-home-drawer", 390, 844, true);
// close via backdrop: click the backdrop element (base-ui renders it as a button/div behind)
await evalJs(`(() => { const b = document.querySelector('[data-slot="dialog-overlay"], .fixed.inset-0.z-40'); if (b) b.click(); const pop = document.querySelector('[data-slot="dialog-content"]'); return "done"; })()`);
await wait(500);
// ensure closed: dispatch Escape
await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await wait(600);
// ---- 3. second lane via chip ----
const chips = await evalJs(`Array.from(document.querySelectorAll("button[aria-pressed]")).map(b => b.textContent).join(" | ")`);
console.log("chips:", chips);
await click('button[aria-pressed="false"]');
await wait(1200);
await shot("m03-home-lane2", 390, 844, true);
// ---- 4. scheduled sheet ----
const dock = await evalJs(`(() => { const b = Array.from(document.querySelectorAll("nav[aria-label='Scheduled to-dos'] button")).pop(); if (!b) return "missing"; b.click(); return "clicked"; })()`);
console.log("dock ->", dock);
await wait(1000);
await shot("m04-scheduled-sheet", 390, 844, true);
await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await wait(600);
// ---- 5. projects page ----
await send("Page.navigate", { url: `${BASE}/projects` });
await wait(2500);
await shot("m05-projects", 390, 844, true);
// ---- 6. chat page ----
await send("Page.navigate", { url: `${BASE}/chat` });
await wait(2500);
await shot("m06-chat", 390, 844, true);
// ---- 7. tablet width (768, desktop shell boundary) ----
await send("Page.navigate", { url: `${BASE}/` });
await wait(2500);
await shot("m07-tablet-768", 768, 1024, false);
// ---- 8. desktop sanity ----
await send("Page.navigate", { url: `${BASE}/` });
await wait(2500);
await shot("m08-desktop-1440", 1440, 900, false);
proc.kill(); process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,109 @@
/* Same sync check as verify-sync.cjs, but on a Project board (verifies the
projectId branch of getBoardSnapshot + the provider's scoping). */
const { spawn } = require("node:child_process");
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
const PORT = 9342;
const BASE = "http://localhost:3210";
function httpJson(p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
let d = ""; res.on("data", (c) => (d += c));
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
});
req.on("error", reject); req.end();
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function sql(statement) {
execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", statement]);
}
(async () => {
const devUserId = execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-tAc", "select id from \"User\" where email='dev@example.com'"]).toString().trim();
// Test project with one open to-do (clean slate regardless of prior runs).
sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`);
sql(`insert into "Project" (id, title, "ownerId", "createdAt", "updatedAt") values ('ptest-proj', 'Sync Test Project', '${devUserId}', now(), now());`);
sql(`insert into "Category" (id, name, "order", "projectId", "createdAt", "updatedAt") values ('ptest-cat', 'Lane A', 0, 'ptest-proj', now(), now());`);
sql(`insert into "Group" (id, title, color, "order", "noteContent", "categoryId", "createdAt", "updatedAt") values ('ptest-grp', 'P group', 'ocean', 0, '', 'ptest-cat', now(), now());`);
sql(`insert into "Todo" (id, title, "order", "groupId", "createdAt", "updatedAt") values ('ptest-todo', 'Project milk', 0, 'ptest-grp', now(), now());`);
const profile = fs.mkdtempSync("/tmp/edge-psync-");
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
let targets = null;
for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
if (!targets) { console.error("no targets"); process.exit(1); }
const page = targets.find((t) => t.type === "page");
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((resolve) => {
const myId = ++id; pending.set(myId, resolve);
ws.send(JSON.stringify({ id: myId, method, params }));
});
const errors = [];
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; }
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) errors.push(txt.slice(0, 200));
}
};
const evalJs = (expression) =>
send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
.then((r) => (r?.result ? r.result.value : r));
await send("Runtime.enable");
await send("Page.enable");
await send("Page.navigate", { url: `${BASE}/login` });
for (let i = 0; i < 60; i++) { await sleep(500); if (await evalJs("document.readyState") === "complete") break; }
const login = await evalJs(`(async () => {
const c = await (await fetch('/api/auth/csrf')).json();
const r = await fetch('/api/auth/callback/credentials', { method: 'POST', redirect: 'manual',
body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }) });
return r.status; })()`);
console.log("login status:", login);
await send("Page.navigate", { url: `${BASE}/projects/ptest-proj` });
let checkboxFound = false;
for (let i = 0; i < 60; i++) {
await sleep(500);
checkboxFound = await evalJs(`!!Array.from(document.querySelectorAll('[data-todo-checkbox]'))
.some((el) => (el.getAttribute('aria-label') || '').includes('Project milk'))`);
if (checkboxFound) break;
}
if (!checkboxFound) { console.error("FAIL: project board checkbox not found"); process.exit(1); }
console.log("Project milk before:", await evalJs(`Array.from(document.querySelectorAll('[data-todo-checkbox]'))
.find((el) => (el.getAttribute('aria-label') || '').includes('Project milk')).getAttribute('aria-checked')`));
const t0 = Date.now();
console.log("simulating phone: completing the project's to-do in the DB...");
sql(`update "Todo" set "completed"=true, "completedAt"=now() where id='ptest-todo';`);
let done = false;
for (let i = 0; i < 60; i++) {
await sleep(1000);
const state = await evalJs(`(() => {
const el = Array.from(document.querySelectorAll('[data-todo-checkbox]'))
.find((el) => (el.getAttribute('aria-label') || '').includes('Project milk'));
if (el) return el.getAttribute('aria-checked');
return document.querySelector('[aria-label="Expand P group"]') ? "collapsed-done" : "missing";
})()`);
if (state === "true" || state === "collapsed-done") { done = true; break; }
}
const elapsed = Math.round((Date.now() - t0) / 1000);
console.log(`project to-do after (${elapsed}s): ${done ? "synced" : "NOT synced"}`);
sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`);
console.log("console errors:", errors.length ? errors : "none");
const ok = done && errors.length === 0;
console.log(ok ? "ALL PASS" : "FAILURES PRESENT");
ws.close(); proc.kill();
process.exit(ok ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });

153
.cdp/verify-sync.cjs Normal file
View File

@ -0,0 +1,153 @@
/* Verifies cross-device board sync: a "second device" (direct DB write)
changes a to-do, and the open desktop tab must pick it up via the 30s
poll -- and immediately when the tab regains visibility. */
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
const PORT = 9341;
const BASE = "http://localhost:3210";
function httpJson(p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
let d = ""; res.on("data", (c) => (d += c));
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
});
req.on("error", reject); req.end();
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function dbTodo(title, completed) {
// Simulates the phone: a change that lands in the DB, not this tab.
return new Promise((resolve) => {
const { execFile } = require("node:child_process");
const sql = `UPDATE "Todo" SET "completed"=${completed}, "completedAt"=${completed ? "now()" : "NULL"} WHERE title='${title}' AND "completed"=${!completed};`;
execFile("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", sql], (err, out, errOut) => {
resolve({ ok: !err, out, errOut });
});
});
}
(async () => {
const profile = fs.mkdtempSync("/tmp/edge-sync-");
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
let targets = null;
for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
if (!targets) { console.error("no targets"); process.exit(1); }
const page = targets.find((t) => t.type === "page");
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((resolve) => {
const myId = ++id; pending.set(myId, resolve);
ws.send(JSON.stringify({ id: myId, method, params }));
});
const consoleErrors = [];
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; }
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) consoleErrors.push(txt.slice(0, 200));
}
};
const evalJs = (expression) =>
send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
.then((r) => (r?.result ? r.result.value : r));
const goto = async (url) => {
await send("Page.enable");
await send("Page.navigate", { url });
// Wait for load to settle.
for (let i = 0; i < 60; i++) {
await sleep(500);
const s = await evalJs("document.readyState");
if (s === "complete") return;
}
throw new Error("page did not load: " + url);
};
const checkboxState = (title) =>
evalJs(`(() => {
const el = Array.from(document.querySelectorAll('[data-todo-checkbox]'))
.find((el) => (el.getAttribute('aria-label') || '').includes(${JSON.stringify(title)}));
return el ? el.getAttribute('aria-checked') : "missing";
})()`);
await send("Runtime.enable");
// --- Login ---
await goto(`${BASE}/login`);
const login = await evalJs(`(async () => {
const c = await (await fetch('/api/auth/csrf')).json();
const r = await fetch('/api/auth/callback/credentials', {
method: 'POST', redirect: 'manual',
body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }),
});
return r.status;
})()`);
console.log("login status:", login);
// --- Reset test data before the board loads (idempotent) ---
await dbTodo("Milk", false);
await dbTodo("Eggs", false);
// --- Open the Home board ---
await goto(`${BASE}/`);
for (let i = 0; i < 60; i++) {
await sleep(500);
if (await evalJs(`!!document.querySelector('[data-todo-checkbox]')`)) break;
}
console.log("Milk before:", await checkboxState('Milk'));
if ((await checkboxState("Milk")) === "missing") { console.error("FAIL: Milk checkbox not found"); process.exit(1); }
// --- Test 1: 30s poll picks up a remote change ---
const t0 = Date.now();
console.log("simulating phone: checking off 'Milk' in the DB...");
await dbTodo("Milk", true);
let saw = "missing";
for (let i = 0; i < 60; i++) {
await sleep(1000);
saw = await checkboxState("Milk");
if (saw === "true") break;
}
const elapsed = Math.round((Date.now() - t0) / 1000);
console.log(`Milk after (${elapsed}s):`, saw);
const test1 = saw === "true" && elapsed <= 45;
console.log(test1 ? "PASS: 30s poll applied the remote change" : "FAIL: poll did not apply the remote change");
// --- Test 2: hidden tab skips, visibilitychange catches up ---
await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })`);
// Let a poll tick fire while "hidden" (interval is 30s; wait 32s).
await sleep(32000);
console.log("simulating phone: checking off 'Eggs' in the DB (tab hidden)...");
await dbTodo("Eggs", true);
await sleep(2000);
const whileHidden = await checkboxState("Eggs");
console.log("Eggs while still hidden:", whileHidden);
await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); document.dispatchEvent(new Event('visibilitychange'))`);
// When the sync lands, 'Groceries' becomes fully completed and the card
// collapses (its checkboxes disappear from the DOM, replaced by an
// "Expand Groceries" disclosure) -- that is exactly how a fully-checked
// group renders, so assert the collapsed card rather than the checkbox.
let settled = false;
for (let i = 0; i < 15; i++) {
await sleep(1000);
const state = await checkboxState("Eggs");
const collapsed = await evalJs(`!!document.querySelector('[aria-label="Expand Groceries"]')`);
if (state === "missing" && collapsed) { settled = true; break; }
if (state === "true") { settled = true; break; }
}
const test2 = settled && whileHidden === "false";
console.log(`Eggs group after tab shown: ${settled ? "synced (card collapsed as fully-complete)" : "NOT synced"}`);
console.log(test2 ? "PASS: visibilitychange triggered immediate catch-up" : "FAIL: visibility catch-up");
console.log("console errors:", consoleErrors.length ? consoleErrors : "none");
const ok = test1 && test2 && consoleErrors.length === 0;
console.log(ok ? "ALL PASS" : "FAILURES PRESENT");
ws.close(); proc.kill();
process.exit(ok ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });

View File

@ -19,6 +19,10 @@ given instance automatically becomes its administrator.
- Per-Group markdown notes and a to-do list with a completion progress
indicator
- Light/dark theme (follows system by default)
- **Per-account theme**: the theme you pick (theme menu, bottom-left) is
saved on that account's profile, so each account keeps its own look —
after an account toggle you land in the other account's theme. Applies
from the first frame of each page load (no flash)
- Installable PWA (add-to-home-screen); requires a live connection to the
server for its database, so there's no offline mode
- Credentials-based accounts (email + password), with an Admin page to:
@ -26,6 +30,24 @@ given instance automatically becomes its administrator.
password, or delete their account
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
below)
- **Profile page** (`/profile`): set a first/last name and a profile photo,
shown in the menu on the left (the photo replaces the initial-letter
avatar when set). The account's default theme lives on the profile too —
pick it from the theme menu (bottom-left of the sidebar); it persists
per account, not per browser
- **Account linking** (Profile page): link multiple accounts on the same
server so one person can juggle more than one identity.
- *Request Account Link* — ask to link by the other account's email;
the request shows "awaiting confirmation" until they respond
- *Requested Account Link* — the recipient's list of pending requests,
with **Create Account Link**, **Deny Account Link**, or
**Deny and Block Account Link** (blocks that account from requesting
again; blocks can be lifted from the *Blocked Account Link Requests*
section)
- *Linked Accounts* — the other side of each confirmed link, with
**Toggle** (signs this browser out and back in as that account — it
never asks for that account's password) and **Remove Link** (either
linked account can do it)
## Tech stack

View File

@ -2,8 +2,11 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import type { ThemeName } from "@/lib/themes";
import type { ProjectDTO } from "@/types/project";
import { SideNavProvider } from "@/components/nav/side-nav-provider";
import { SideNav } from "@/components/nav/side-nav";
import { MobileTopBar } from "@/components/nav/mobile-top-bar";
import { ProjectsProvider } from "@/components/projects/projects-context";
import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider";
import { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
@ -18,27 +21,59 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// Just the list for the sidebar/`/projects` page -- each project's own
// board (categories/groups/todos) is fetched separately by its own page.
const projects = await prisma.project.findMany({
where: { ownerId: session.user.id },
orderBy: { createdAt: "asc" },
select: { id: true, title: true },
});
// `theme` is stored as free text; the set of valid names is enforced
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
const [projects, profile] = await Promise.all([
prisma.project.findMany({
where: { ownerId: session.user.id },
orderBy: { createdAt: "asc" },
select: { id: true, title: true, theme: true },
}),
// The user's profile for the sidebar's bottom user block (name + photo,
// both optional); the /profile page fetches the same row itself.
prisma.user.findUnique({
where: { id: session.user.id },
select: { firstName: true, lastName: true, avatar: true },
}),
]);
const projectList: ProjectDTO[] = projects.map((p) => ({
id: p.id,
title: p.title,
theme: p.theme as ThemeName | null,
}));
const userName =
[profile?.firstName, profile?.lastName]
.filter((part): part is string => Boolean(part && part.trim()))
.map((part) => part.trim())
.join(" ") || null;
return (
<SideNavProvider>
<ScheduledPanelProvider>
<ProjectsProvider initialProjects={projects}>
<ProjectsProvider initialProjects={projectList}>
<BoardViewProvider>
<HoldOnCompleteProvider>
{/* On small screens the three-column shell would crush the
board; give it a sensible minimum width and let the page
scroll horizontally instead, and drop the Scheduled panel
(auxiliary on a phone) until the viewport can hold it. */}
<div className="flex h-screen min-w-[860px] overflow-x-auto">
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main>
<ScheduledPanel />
</div>
{/* Responsive shell:
- md and up: the original three-column desktop layout, with
the same min-width + horizontal-scroll fallback as before.
- below md (phones): a true stacked app layout -- top bar,
full-height board, and the Scheduled dock (rendered inside
ScheduledPanel) take the full column with no horizontal
scrolling; h-dvh tracks the mobile browser's dynamic
toolbar instead of the stale 100vh. */}
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
<MobileTopBar />
<SideNav
userEmail={session.user.email ?? ""}
userName={userName}
avatar={profile?.avatar ?? null}
role={session.user.role}
/>
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
<ScheduledPanel />
</div>
</HoldOnCompleteProvider>
</BoardViewProvider>
</ProjectsProvider>

134
app/(app)/profile/page.tsx Normal file
View File

@ -0,0 +1,134 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import type {
BlockedRequesterDTO,
LinkedAccountDTO,
PendingLinkRequestDTO,
ProfileDTO,
} from "@/types/profile";
import { ProfileForm } from "@/components/profile/profile-form";
import { RequestLinkForm } from "@/components/profile/request-link-form";
import { PendingLinkRequests } from "@/components/profile/pending-link-requests";
import { LinkedAccounts } from "@/components/profile/linked-accounts";
import { BlockedLinkRequests } from "@/components/profile/blocked-link-requests";
const LINK_ACCOUNT_SELECT = {
id: true,
email: true,
name: true,
firstName: true,
lastName: true,
avatar: true,
} as const;
type LinkAccountRow = {
id: string;
email: string;
name: string | null;
firstName: string | null;
lastName: string | null;
avatar: string | null;
};
/** Display name for another account: profile first/last name when set,
* falling back to the legacy sign-up name. */
function identity(row: LinkAccountRow) {
const full = [row.firstName, row.lastName].filter(Boolean).join(" ").trim();
return {
id: row.id,
email: row.email,
name: full || row.name?.trim() || null,
avatar: row.avatar,
};
}
function dateLabel(date: Date) {
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default async function ProfilePage() {
const session = await auth();
// Defense in depth: proxy.ts already redirects unauthenticated requests,
// but every protected data boundary should check for itself too.
if (!session?.user) redirect("/login");
const userId = session.user.id;
const [user, pendingRequests, links, blocks] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { name: true, firstName: true, lastName: true, avatar: true },
}),
// Requests addressed to this account -- the ones it can act on.
prisma.accountLinkRequest.findMany({
where: { toUserId: userId },
orderBy: { createdAt: "desc" },
include: { fromUser: { select: LINK_ACCOUNT_SELECT } },
}),
// Confirmed links in either direction; the other side of the pair is
// whichever isn't this account.
prisma.accountLink.findMany({
where: { OR: [{ userId }, { linkedUserId: userId }] },
include: {
user: { select: LINK_ACCOUNT_SELECT },
linkedUser: { select: LINK_ACCOUNT_SELECT },
},
}),
// Blocks this account issued: who is barred from requesting it.
prisma.accountLinkBlock.findMany({
where: { toUserId: userId },
orderBy: { createdAt: "desc" },
include: { fromUser: { select: LINK_ACCOUNT_SELECT } },
}),
]);
if (!user) redirect("/login");
const profile: ProfileDTO = {
firstName: user.firstName,
lastName: user.lastName,
avatar: user.avatar,
};
const pending: PendingLinkRequestDTO[] = pendingRequests.map((request) => ({
requestId: request.id,
requestedAtLabel: dateLabel(request.createdAt),
...identity(request.fromUser),
}));
const linked: LinkedAccountDTO[] = links.map((link) => ({
linkId: link.id,
...identity(link.userId === userId ? link.linkedUser : link.user),
}));
const blocked: BlockedRequesterDTO[] = blocks.map((block) => ({
blockId: block.id,
blockedAtLabel: dateLabel(block.createdAt),
...identity(block.fromUser),
}));
return (
<div className="flex h-full flex-col gap-4 p-4">
<div className="px-1">
<h1 className="font-heading text-xl font-bold tracking-tight">Profile</h1>
<p className="mt-0.5 text-[13px] text-muted-foreground">
Your name and photo, shown in the menu on the left and your
links to other accounts on this server.
</p>
</div>
<ProfileForm initial={profile} />
<div className="flex max-w-xl flex-col gap-4">
<RequestLinkForm />
{pending.length > 0 && <PendingLinkRequests requests={pending} />}
{linked.length > 0 && <LinkedAccounts accounts={linked} />}
{blocked.length > 0 && <BlockedLinkRequests blocks={blocked} />}
</div>
</div>
);
}

View File

@ -5,7 +5,9 @@ import { prisma } from "@/lib/db";
import { projectAccessFilter } from "@/lib/access";
import { getBoard } from "@/lib/board";
import { getAiSettingsView } from "@/lib/ai-settings";
import { themeSwitchScript, type ThemeName } from "@/lib/themes";
import { KanbanBoard } from "@/components/board/kanban-board";
import { ProjectThemeScope } from "@/components/theme/project-theme-scope";
export default async function ProjectPage({
params,
@ -18,7 +20,7 @@ export default async function ProjectPage({
const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(session.user.id) },
select: { id: true, title: true },
select: { id: true, title: true, theme: true },
});
// Same response whether the project doesn't exist or just isn't this
// user's -- no need to distinguish "not found" from "not yours".
@ -30,12 +32,28 @@ export default async function ProjectPage({
]);
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
// A project's assigned theme (null = "Default Theme", i.e. the user's
// global theme-menu choice) applies while this page is open and lifts on
// navigation -- ProjectThemeScope owns the client side. The inline script
// mirrors it on <html> before first paint on a hard load, so the first
// frame is already themed (and leaves the data-project-theme marker the
// ThemeProvider reads at hydration). `theme` is free text in the DB; the
// set of valid names is enforced by ProjectThemeSchema on write, so the
// assertion is safe.
const scopedTheme: ThemeName | null = project.theme as ThemeName | null;
return (
<KanbanBoard
initialCategories={board}
projectId={project.id}
title={project.title}
aiConfigured={aiConfigured}
/>
<>
{scopedTheme && (
<script dangerouslySetInnerHTML={{ __html: themeSwitchScript(scopedTheme) }} />
)}
<ProjectThemeScope projectId={project.id} />
<KanbanBoard
initialCategories={board}
projectId={project.id}
title={project.title}
aiConfigured={aiConfigured}
/>
</>
);
}

View File

@ -980,6 +980,34 @@ html.theme-blueprint {
html.theme-blueprint [data-slot="button"] {
position: relative;
}
/* Paper surfaces (dialogs, popovers, dropdown menus) carry cobalt ink:
they set `text-popover-foreground`, but the generic button and editor
styles also read `--foreground` / `--background` expecting the usual
light-theme semantics (dark ink, light surface). In blueprint those
tokens are board-level (near-white ink, cobalt surface), so ghost
buttons wash to white-on-white, outline buttons go blue-on-blue, and
the markdown editor's text is invisible on the sheet. Restoring the
paper semantics locally makes every descendant render correctly. */
html.theme-blueprint [data-slot="dialog-content"],
html.theme-blueprint [data-slot="popover-content"],
html.theme-blueprint [data-slot="dropdown-menu-content"] {
--foreground: var(--card-foreground);
--background: var(--card);
}
/* The sidebar and scheduled rail are cobalt board chrome with near-white
ink. The ghost buttons there (theme trigger, log out, collapse, rail
actions) reference `--muted` / `--foreground` for hover + expanded
states, which in blueprint are both paper-bright: a white-on-white
wash-out. Re-target those states to the sidebar's own accent pair:
a slightly lighter cobalt with the same near-white ink. */
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"]):hover,
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"]):focus-visible,
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"])[aria-expanded="true"] {
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
html.theme-blueprint [data-slot="button"]::before,
html.theme-blueprint [data-slot="button"]::after {
content: "";
@ -1720,17 +1748,34 @@ html.theme-vaporwave .vw-palm--right-small {
that inline value (animations sit above inline styles in the cascade) --
the card stops following the pointer. opacity-50 is exactly the class the
card gets while dragging, so the hiccup stands down for the duration.
(overflow: hidden keeps the tracking band clipped to the card while it
sweeps; the hover lift uses the separate `translate` property, so the
jitter doesn't fight it.) */
(The tracking band sweeps inside a .vw-glitch-clip clip box rendered by
GroupCard rather than relying on overflow: hidden on the card itself --
the card must stay overflow-visible so the progress pie, which pokes
outside the bottom-right corner, isn't cropped by the card or its
rounded-2xl corner. The hover lift uses the separate `translate`
property, so the jitter doesn't fight it.) */
html.theme-vaporwave [data-group-card]:not(.opacity-50) {
overflow: hidden;
animation: vw-card-glitch 10s linear infinite;
}
html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 {
animation: vw-title-glitch 10s linear infinite;
}
html.theme-vaporwave [data-group-card]:not(.opacity-50)::after {
/* The band's clip box: a full-card overlay (in GroupCard) that carries the
overflow clipping, so the card itself never needs it. inset-0 +
border-radius: inherit matches the card's own padding-box clip exactly,
sweep included. Hidden in every other theme. */
.vw-glitch-clip {
display: none;
}
html.theme-vaporwave .vw-glitch-clip {
display: block;
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
}
html.theme-vaporwave [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
content: "";
position: absolute;
left: 0;
@ -1756,14 +1801,14 @@ html.theme-vaporwave [data-group-card]:not(.opacity-50)::after {
first delay elapses, and under reduced motion, everything just sits still.) */
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50),
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) > div:first-child h3,
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50)::after {
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
animation-duration: 13s;
animation-delay: 3s;
}
/* Phase C: the longest period, offset furthest. */
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50),
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) > div:first-child h3,
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50)::after {
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
animation-duration: 17s;
animation-delay: 7s;
}

View File

@ -15,6 +15,9 @@ import { ThemeProvider } from "@/components/theme/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { RegisterServiceWorker } from "@/components/pwa/register-sw";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { themeInitScript, type RawTheme } from "@/lib/themes";
/** UI type: Inter -- a neutral, highly legible humanist sans that reads
* cleanly at small sizes (to-do lists, tables, nav). */
@ -94,7 +97,28 @@ export const viewport: Viewport = {
],
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default async function RootLayout({ children }: LayoutProps<"/">) {
// Per-user global theme (stored on the profile row; set from the theme
// menu, see saveUserTheme in lib/actions/profile.ts). When signed in it
// is authoritative: the no-FOUC script below applies it before first
// paint and the ThemeProvider starts from it and persists changes back
// -- so each account in a linked set keeps its own look in the same
// browser across account toggles (a toggle is a full navigation, and
// this layout re-renders for the new user). Anonymous visitors keep the
// old localStorage-based behavior.
const session = await auth();
let userTheme: RawTheme | undefined;
if (session?.user) {
// Stored as free text; valid values are enforced by ThemeSchema
// (lib/validation/profile.ts) at write time, so the assertion is safe
// -- same pattern as Project.theme.
const profile = await prisma.user.findUnique({
where: { id: session.user.id },
select: { theme: true },
});
userTheme = (profile?.theme as RawTheme | null) ?? "system";
}
return (
<html
lang="en"
@ -110,10 +134,13 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
their own fonts, backgrounds, and animations.
"system" resolves to Default/Dark by OS preference.
No-FOUC theme restore: runs before first paint, applies the
stored preference to <html> (class + color-scheme). */}
right class + color-scheme to <html>. Signed-in users get
their profile's stored theme (per account, so it survives
account toggles); anonymous visitors get the browser's
localStorage preference -- see themeInitScript in lib/themes.ts. */}
<script
dangerouslySetInnerHTML={{
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','theme-meadow','theme-honey','theme-rose','theme-lavender','theme-slate','theme-blueprint','theme-vaporwave','theme-notebook','dark','ocean','theme-pine','theme-plum','theme-midnight','theme-ember','theme-rosewood','theme-cyberpunk','theme-starfield','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',meadow:'theme-meadow',honey:'theme-honey',rose:'theme-rose',lavender:'theme-lavender',slate:'theme-slate',blueprint:'theme-blueprint',vaporwave:'theme-vaporwave',notebook:'theme-notebook',dark:'dark',ocean:'ocean',pine:'theme-pine',plum:'theme-plum',midnight:'theme-midnight',ember:'theme-ember',rosewood:'theme-rosewood',cyberpunk:'theme-cyberpunk',starfield:'theme-starfield'};var d=['dark','ocean','pine','plum','midnight','ember','rosewood','cyberpunk','starfield'];if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';}}catch(e){}})();`,
__html: themeInitScript(userTheme),
}}
/>
{/* Vaporwave scenery (see app/globals.css): the outrun floor grid,
@ -146,7 +173,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<svg className="vw-palm vw-palm--right-small" aria-hidden>
<use href="#vw-palm" />
</svg>
<ThemeProvider defaultTheme="system">
<ThemeProvider userTheme={userTheme}>
<TooltipProvider delay={200}>
{children}
<Toaster />

47
auth.ts
View File

@ -5,7 +5,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { LoginSchema } from "@/lib/validation/auth";
import { Role } from "@/lib/generated/prisma/enums";
import { PendingAccountSignin } from "@/lib/auth-errors";
import { PendingAccountSignin, SwitchAccountSignin } from "@/lib/auth-errors";
// Credentials-only, JWT sessions, no database adapter: with a single
// Credentials provider and no OAuth, there's nothing for a DB-backed
@ -20,7 +20,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
signIn: "/login",
},
providers: [
// Explicit id: with a second Credentials provider ("account-switch"
// below) the providers must be disambiguated by id, and existing
// signIn("credentials", ...) calls keep working against this one.
Credentials({
id: "credentials",
credentials: {
email: {},
password: {},
@ -40,6 +44,47 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// email confirmation) and can't log in yet -- see lib/settings.ts.
if (user.role === Role.PENDING) throw new PendingAccountSignin();
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
// Signs a user in as one of the accounts their own account is linked
// to -- the Profile page "Toggle" button -- without knowing that
// account's password. The only credential accepted is a one-time
// token minted by toggleAccount (lib/actions/account-links.ts)
// *after* verifying the link exists, so holding a token is what
// proves eligibility; this provider just exchanges it for a session.
// The token is consumed here (usedAt set) so a replayed
// /api/auth/callback/account-switch request can never switch twice.
Credentials({
id: "account-switch",
name: "AccountSwitch",
credentials: {
token: {},
},
async authorize(rawCredentials) {
const token = typeof rawCredentials?.token === "string" ? rawCredentials.token : "";
if (!token) throw new SwitchAccountSignin();
const row = await prisma.accountSwitchToken.findUnique({ where: { token } });
if (!row || row.usedAt || row.expiresAt <= new Date()) {
throw new SwitchAccountSignin();
}
// Atomically claim the token. If a concurrent request already did,
// count is 0 and this attempt fails even though the read above passed.
const claimed = await prisma.accountSwitchToken.updateMany({
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
if (claimed.count === 0) throw new SwitchAccountSignin();
const user = await prisma.user.findUnique({ where: { id: row.targetUserId } });
if (!user) throw new SwitchAccountSignin();
// Same rule as the credentials provider: PENDING accounts can't
// hold a session at all.
if (user.role === Role.PENDING) throw new SwitchAccountSignin();
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),

View File

@ -49,8 +49,8 @@ export function AdminUserTable({
return (
<>
<div className="max-w-3xl overflow-hidden rounded-xl border">
<table className="w-full text-sm">
<div className="max-w-3xl overflow-x-auto rounded-xl border">
<table className="w-full min-w-[560px] text-sm">
<thead className="bg-muted/40 text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>

View File

@ -39,7 +39,10 @@ export function AddCategoryLane() {
render={
<button
data-board-chrome=""
className="flex h-full min-h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary"
// Mobile: a full-width snap page below the lanes (stacked under
// the EmptyState, full-width next to them); desktop: the usual
// dashed lane at the end of the row.
className="flex h-40 w-full shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary snap-start md:h-full md:min-h-32 md:w-72"
aria-label="Add category"
>
<span className="flex size-9 items-center justify-center rounded-full bg-foreground/5">

View File

@ -10,16 +10,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider";
import { useHoldOnComplete } from "@/components/hold-on-complete";
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
const TITLE_MAX = 20;
// Compact hides any group with nothing open in it -- without a hold, a
// brand-new (necessarily empty) group would never appear at all. Longer
// than the usual 6s grace period since there's no accidental click to
// forgive here; this is purely "give it a moment to be noticed / add a
// to-do to it before it disappears".
const NEW_GROUP_HOLD_MS = 15_000;
export function AddGroupPopover({ categoryId }: { categoryId: string }) {
const { addGroup } = useBoard();

View File

@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import { toast } from "sonner";
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
@ -25,6 +25,13 @@ import {
deleteTodo as deleteTodoAction,
} from "@/lib/actions/todos";
import { updateGroupNote } from "@/lib/actions/notes";
import { getBoardSnapshot } from "@/lib/actions/board";
// How often a visible tab checks for changes made on another device (see
// the sync effect in BoardProvider). Deliberately not real time: a phone
// tap lands here within a tick at most -- and immediately if the tab only
// just came to the foreground.
const POLL_INTERVAL_MS = 30_000;
interface BoardContextValue {
categories: CategoryDTO[];
@ -75,6 +82,10 @@ interface BoardContextValue {
completed: boolean
) => Promise<void>;
removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
// True while a dnd-kit drag is live -- a state replacement mid-drag would
// swap the SortableContext items out from under it, so remote sync
// defers until the drag ends.
suspendRemoteSync: (suspended: boolean) => void;
}
const BoardContext = createContext<BoardContextValue | null>(null);
@ -96,6 +107,94 @@ export function BoardProvider({
}) {
const [categories, setCategories] = useState(initialCategories);
// -- Cross-device sync ---------------------------------------------------
// The board is seeded once from the server and otherwise only changed by
// this tab's own optimistic mutations, so edits made on a second device
// (checking off a to-do on a phone, renaming a lane, ...) never reach
// this tab on their own. The poll below closes that gap: fetch a fresh
// snapshot and, if it differs from local state and no local write is
// pending or just landed, apply it. The refs are the guards that keep a
// poll from ever clobbering local work.
// Always mirrors `categories` for the diff below (updated by the effect
// under it rather than every setCategories call site).
const categoriesRef = useRef(categories);
// Local mutations in flight (optimistic state ahead of the server).
const inflightRef = useRef(0);
// True while a drag is live (set from Board's drag start/end).
const remoteSyncSuspendedRef = useRef(false);
// A poll is already running.
const pollingRef = useRef(false);
// When the most recent local mutation settled (success or rollback).
const lastLocalWriteAtRef = useRef(0);
useEffect(() => {
categoriesRef.current = categories;
}, [categories]);
// Marks a server action as "local work in progress" for the poll's
// duration, and stamps when it settled. Every mutation below goes
// through this so the sync effect can tell "state we are about to lose"
// from "state we will happily overwrite with the server's copy".
const track = useCallback(
<T,>(promise: Promise<T>): Promise<T> => {
inflightRef.current += 1;
return promise.finally(() => {
inflightRef.current -= 1;
lastLocalWriteAtRef.current = Date.now();
});
},
[]
);
const suspendRemoteSync = useCallback((suspended: boolean) => {
remoteSyncSuspendedRef.current = suspended;
}, []);
useEffect(() => {
let disposed = false;
async function refreshBoard() {
if (disposed || pollingRef.current) return;
if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
// No point fetching while the tab is hidden; the visibilitychange
// handler below catches up the moment it is shown again.
if (document.visibilityState !== "visible") return;
pollingRef.current = true;
try {
const board = await getBoardSnapshot(projectId ?? null);
if (disposed) return;
// Re-check the guards at apply time, not just at start: a mutation
// (or drag) that began while the fetch was in flight makes this
// snapshot stale relative to local state. A write that settled in
// the last couple of seconds may post-date the snapshot's read on
// the server too, so defer it -- the next tick picks everything up.
if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
if (Date.now() - lastLocalWriteAtRef.current < 2000) return;
// The common case is "nothing changed" -- skip the setState so the
// whole board doesn't re-render every tick.
if (JSON.stringify(board) === JSON.stringify(categoriesRef.current)) return;
setCategories(board);
} catch {
// Offline or a server hiccup -- the next tick retries. (If the
// session itself is gone, the page's own navigation handles it.)
} finally {
pollingRef.current = false;
}
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") void refreshBoard();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
const timer = window.setInterval(() => void refreshBoard(), POLL_INTERVAL_MS);
return () => {
disposed = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.clearInterval(timer);
};
}, [projectId]);
const updateCategory = useCallback(
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
@ -116,13 +215,13 @@ export function BoardProvider({
const addCategory = useCallback(
async (name: string) => {
try {
const category = await createCategory(name, projectId);
const category = await track(createCategory(name, projectId));
setCategories((prev) => [...prev, category]);
} catch {
toast.error("Couldn't create category. Try again.");
}
},
[projectId]
[projectId, track]
);
const removeCategory = useCallback(async (categoryId: string) => {
@ -132,7 +231,7 @@ export function BoardProvider({
return prev.filter((c) => c.id !== categoryId);
});
try {
const result = await deleteCategoryAction(categoryId);
const result = await track(deleteCategoryAction(categoryId));
if (result?.error) {
setCategories(prevState);
toast.error(result.error);
@ -141,7 +240,7 @@ export function BoardProvider({
setCategories(prevState);
toast.error("Couldn't delete category. Try again.");
}
}, []);
}, [track]);
const reorderLanes = useCallback(
async (orderedIds: string[]) => {
@ -152,36 +251,36 @@ export function BoardProvider({
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
});
try {
await reorderCategories(orderedIds, projectId);
await track(reorderCategories(orderedIds, projectId));
} catch {
setCategories(prevState);
toast.error("Couldn't reorder categories. Try again.");
}
},
[projectId]
[projectId, track]
);
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
try {
const group = await createGroup(categoryId, title, color);
const group = await track(createGroup(categoryId, title, color));
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
return group;
} catch {
toast.error("Couldn't create group. Try again.");
return undefined;
}
}, [updateCategory]);
}, [updateCategory, track]);
const editGroup = useCallback(
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
try {
await updateGroupAction(groupId, data);
await track(updateGroupAction(groupId, data));
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
} catch {
toast.error("Couldn't update group. Try again.");
}
},
[updateGroupInState]
[updateGroupInState, track]
);
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
@ -193,12 +292,12 @@ export function BoardProvider({
);
});
try {
await deleteGroupAction(groupId);
await track(deleteGroupAction(groupId));
} catch {
setCategories(prevState);
toast.error("Couldn't delete group. Try again.");
}
}, []);
}, [track]);
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
let prevState: CategoryDTO[] = [];
@ -209,12 +308,12 @@ export function BoardProvider({
);
});
try {
await archiveGroupAction(groupId);
await track(archiveGroupAction(groupId));
} catch {
setCategories(prevState);
toast.error("Couldn't archive group. Try again.");
}
}, []);
}, [track]);
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
let prevState: CategoryDTO[] = [];
@ -227,12 +326,12 @@ export function BoardProvider({
});
});
try {
await reorderGroupsInCategory(categoryId, orderedIds);
await track(reorderGroupsInCategory(categoryId, orderedIds));
} catch {
setCategories(prevState);
toast.error("Couldn't reorder groups. Try again.");
}
}, []);
}, [track]);
const moveGroup = useCallback(
async (
@ -273,19 +372,19 @@ export function BoardProvider({
});
});
try {
await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource);
await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource));
} catch {
setCategories(prevState);
toast.error("Couldn't move group. Try again.");
}
},
[]
[track]
);
const saveNote = useCallback(
async (groupId: string, categoryId: string, noteContent: string) => {
try {
await updateGroupNote(groupId, noteContent);
await track(updateGroupNote(groupId, noteContent));
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
return true;
} catch {
@ -293,13 +392,13 @@ export function BoardProvider({
return false;
}
},
[updateGroupInState]
[updateGroupInState, track]
);
const addTodo = useCallback(
async (groupId: string, categoryId: string, title: string, details?: string) => {
try {
const todo = await createTodo(groupId, title, details);
const todo = await track(createTodo(groupId, title, details));
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
return true;
} catch {
@ -307,13 +406,13 @@ export function BoardProvider({
return false;
}
},
[updateGroupInState]
[updateGroupInState, track]
);
const addTodos = useCallback(
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
try {
const created = await createTodos(groupId, todos);
const created = await track(createTodos(groupId, todos));
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
return true;
} catch {
@ -321,7 +420,7 @@ export function BoardProvider({
return false;
}
},
[updateGroupInState]
[updateGroupInState, track]
);
const editTodo = useCallback(
@ -332,7 +431,7 @@ export function BoardProvider({
data: { title?: string; details?: string | null }
) => {
try {
await updateTodoAction(todoId, data);
await track(updateTodoAction(todoId, data));
// Approximates the server's own `updatedAt` (set by the same write,
// a moment later) closely enough for display purposes, without
// waiting on a round trip just to read it back.
@ -347,7 +446,7 @@ export function BoardProvider({
return false;
}
},
[updateGroupInState]
[updateGroupInState, track]
);
const toggleTodoDone = useCallback(
@ -370,13 +469,13 @@ export function BoardProvider({
}),
}));
try {
await toggleTodoAction(todoId, completed);
await track(toggleTodoAction(todoId, completed));
} catch {
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
toast.error("Couldn't update to-do. Try again.");
}
},
[updateCategory, updateGroupInState]
[updateCategory, updateGroupInState, track]
);
const removeTodo = useCallback(
@ -391,13 +490,13 @@ export function BoardProvider({
}),
}));
try {
await deleteTodoAction(todoId);
await track(deleteTodoAction(todoId));
} catch {
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
toast.error("Couldn't delete to-do. Try again.");
}
},
[updateCategory, updateGroupInState]
[updateCategory, updateGroupInState, track]
);
return (
@ -420,6 +519,7 @@ export function BoardProvider({
editTodo,
toggleTodoDone,
removeTodo,
suspendRemoteSync,
}}
>
{children}

View File

@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import type { CategoryDTO } from "@/types/board";
/**
* Mobile (< md) category rail: one chip per lane, shown under the top bar.
* Doubles as the board's position indicator while swiping between lanes
* (the active chip is highlighted, driven by the board's
* IntersectionObserver) and as quick navigation -- tapping a chip glides
* the lane pager to that lane.
*/
export function CategoryChips({
categories,
activeCategoryId,
onSelect,
}: {
categories: CategoryDTO[];
activeCategoryId: string | null;
onSelect: (id: string) => void;
}) {
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
// Keep the active chip in view if the user swipes several lanes at once
// (or a chip gets added/removed out of the strip's view).
useEffect(() => {
if (!activeCategoryId) return;
chipRefs.current
.get(activeCategoryId)
?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
}, [activeCategoryId]);
return (
<div
// -mx-3/px-3 bleeds the strip to the screen edges (the board's own
// p-3) so chips start and end flush with the viewport, the way a
// native tab strip does.
className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 overscroll-x-contain"
>
{categories.map((category) => {
const active = category.id === activeCategoryId;
const openTodos = category.groups.reduce(
(n, g) => n + g.todos.filter((t) => !t.completed).length,
0
);
return (
<button
key={category.id}
ref={(el) => {
if (el) chipRefs.current.set(category.id, el);
else chipRefs.current.delete(category.id);
}}
type="button"
onClick={() => onSelect(category.id)}
aria-pressed={active}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors",
active
? "border-transparent bg-primary text-primary-foreground shadow-sm"
: "border-border bg-lane text-muted-foreground active:bg-accent active:text-accent-foreground"
)}
>
{category.name}
<span
className={cn(
"rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
active ? "bg-primary-foreground/20" : "bg-foreground/8"
)}
aria-label={`${openTodos} open to-dos`}
>
{openTodos}
</span>
</button>
);
})}
</div>
);
}

View File

@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, MoreVertical, Plus, Trash2 } from "lucide-react";
import { GripVertical, MoreVertical, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
@ -98,9 +98,13 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
<div
ref={setNodeRef}
data-category-lane=""
data-category-id={category.id}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane",
// w-full on mobile: the lane pager gives each lane the whole
// screen width (one lane per swipe, see kanban-board's pager);
// w-72 side-by-side at md and up.
"flex h-full w-full shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane snap-start md:w-72",
isDragging && "opacity-50"
)}
>
@ -162,7 +166,16 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
// leaves enough clearance that a card's hover lift
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
// under the lane header above it.
"flex-1 space-y-2 overflow-y-auto rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
//
// overflow-x-clip: the lane is a fixed-width (w-72) panel whose
// cards always span it, so horizontal scrolling is never wanted.
// The only things that can spill horizontally are corner badges
// like the group card's progress pie, which deliberately poke
// past the card's right edge. `clip` (not `hidden`) keeps
// overflow-y-auto fully functional while simply discarding any
// spill, so a future badge that strays a px too far can never
// grow a stray horizontal scrollbar here.
"flex-1 space-y-2 overflow-y-auto overflow-x-clip rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
isDropTargetLane && "bg-primary/10"
)}
>

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
@ -32,7 +32,7 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider";
import { useHoldOnComplete } from "@/components/hold-on-complete";
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
import { NotesDialog } from "@/components/board/notes-dialog";
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
@ -89,7 +89,7 @@ function AddTodoMenu({
export function GroupCard({ group }: { group: GroupDTO }) {
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
const { view } = useBoardView();
const { holds, beginHold, cancelHold } = useHoldOnComplete();
const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete();
const compact = view === "compact";
// True for any theme whose surfaces are dark -- Dark and Ocean both use
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
@ -101,6 +101,39 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
// True while one of this card's "add to-do" windows is open and we paused
// the group's own hold to keep the card (and the window mounted inside it)
// from unmounting mid-form -- a brand-new empty group in compact view is
// the case that needs it: its creation hold would otherwise expire while
// the user is still typing and close the window right out from under them.
const addTodoHoldPausedRef = useRef(false);
// Opens or closes one of this card's "add to-do" windows (the plain
// dialog or the AI dialog). While at least one is open, a group that is
// on screen only because of its own hold (i.e. still empty) gets that
// hold frozen, so the card -- and the window mounted inside it -- can't
// be unmounted mid-form. When the last window closes, a still-empty
// group gets a fresh grace period to be noticed or filled before finally
// fading out; a group the window did fill drops the paused hold entirely
// (it stays visible for its own open work, and a leftover hold entry
// would otherwise pin it to compact view forever).
function handleAddTodoWindow(
next: boolean,
setter: (open: boolean) => void,
otherOpen: boolean
) {
setter(next);
if (next) {
if (!otherOpen && compact && holds.has(group.id)) {
addTodoHoldPausedRef.current = true;
pauseHold(group.id);
}
} else if (!otherOpen && addTodoHoldPausedRef.current) {
addTodoHoldPausedRef.current = false;
if (group.todos.some((t) => !t.completed)) cancelHold(group.id);
else beginHold(group.id, NEW_GROUP_HOLD_MS);
}
}
const {
attributes,
@ -341,8 +374,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<AddTodoMenu
group={group}
aiConfigured={aiConfigured}
onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
/>
)}
</div>
@ -362,8 +395,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<div className="mt-1 flex justify-end">
<AddTodoMenu
group={group}
onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
aiConfigured={aiConfigured}
/>
</div>
@ -406,7 +439,11 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
</Tooltip>
)}
<span className="ml-auto text-[11px] font-medium tabular-nums text-muted-foreground">
{/* pr-2.5: the corner progress pie (TodoProgressPie) hugs
the card's right edge right here, so the label keeps a
little room of its own -- together the two clear each
other without either spilling past the lane's edge. */}
<span className="ml-auto pr-2.5 text-[11px] font-medium tabular-nums text-muted-foreground">
{completedCount}/{group.todos.length} done
</span>
</div>
@ -421,6 +458,14 @@ export function GroupCard({ group }: { group: GroupDTO }) {
)}
</>
)}
{/* Vaporwave's VHS tracking band sweeps inside this clip box (see
.vw-glitch-clip in app/globals.css) instead of the card itself
using overflow: hidden -- the card must stay overflow-visible so
the progress pie can poke past the bottom-right corner without
being clipped by it or the card's rounded corner. display:none
(and thus inert) in every other theme. */}
<div className="vw-glitch-clip" aria-hidden />
</div>
{editingTodo && (
@ -435,13 +480,17 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
<TodoAiDialog
group={group}
open={todoAiOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
/>
<TodoCreateDialog
groupId={group.id}
categoryId={group.categoryId}
open={todoCreateOpen}
onOpenChange={setTodoCreateOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
/>
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
DndContext,
DragOverlay,
@ -17,12 +17,14 @@ import { LayoutDashboard, Plus } from "lucide-react";
import { BoardProvider, useBoard } from "@/components/board/board-context";
import { CategoryLane } from "@/components/board/category-lane";
import { CategoryChips } from "@/components/board/category-chips";
import { AddCategoryLane } from "@/components/board/add-category-lane";
import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state";
import { ViewSwitcher } from "@/components/board/view-switcher";
import { SummaryButton } from "@/components/board/summary-button";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
import { ProjectThemePicker } from "@/components/projects/project-theme-picker";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { CategoryDTO, GroupDTO } from "@/types/board";
@ -36,9 +38,14 @@ function Board({
projectId?: string;
aiConfigured: boolean;
}) {
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = useBoard();
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
const [quickAddOpen, setQuickAddOpen] = useState(false);
const pagerRef = useRef<HTMLDivElement>(null);
// Which lane is front-and-center in the mobile pager (see the observer
// below) -- null until the first intersection report lands, after which
// CategoryChips falls back to the first lane for the brief gap.
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
// The "+ to-do" quick action targets the first group it can find -- the
// goal is one click from a keyboard or pointer to start typing a to-do
@ -60,6 +67,11 @@ function Board({
}
function handleDragStart(event: DragStartEvent) {
// Hold cross-device sync off for the drag's duration: replacing the
// board state mid-drag would swap the SortableContext items out from
// under dnd-kit. Drag ends (including cancelled ones) always fire
// handleDragEnd, which resumes it.
suspendRemoteSync(true);
const data = event.active.data.current;
if (data?.type === "group") {
const category = findCategory(data.categoryId as string);
@ -69,6 +81,7 @@ function Board({
}
function handleDragEnd(event: DragEndEvent) {
suspendRemoteSync(false);
const { active, over } = event;
setDraggedGroup(null);
if (!over) return;
@ -128,6 +141,51 @@ function Board({
const categoryIds = categories.map((c) => c.id);
// Mobile pager awareness: watch the lanes inside the scroll pager and
// keep the chip rail in sync with whichever lane is most visible. Only
// runs when lanes actually exist; re-runs when the lane set changes so
// added/removed lanes get observed. (No-op on desktop -- the lanes are
// there, but the chips are hidden, so the state is simply unused.)
useEffect(() => {
const pager = pagerRef.current;
if (!pager) return;
const lanes = Array.from(pager.querySelectorAll<HTMLElement>("[data-category-id]"));
if (lanes.length === 0) return;
const ratios = new Map<HTMLElement, number>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const el = entry.target as HTMLElement;
if (entry.isIntersecting) ratios.set(el, entry.intersectionRatio);
else ratios.delete(el);
}
let best: HTMLElement | null = null;
let bestRatio = 0;
for (const lane of lanes) {
const ratio = ratios.get(lane) ?? 0;
if (ratio > bestRatio) {
bestRatio = ratio;
best = lane;
}
}
if (best?.dataset.categoryId) setActiveCategoryId(best.dataset.categoryId);
},
{ root: pager, threshold: [0.25, 0.5, 0.75, 1] }
);
lanes.forEach((lane) => observer.observe(lane));
return () => observer.disconnect();
}, [categories]);
// Chip tap: glide the pager to that lane (snap then settles it exactly
// onto the lane's start edge).
function handleSelectChip(id: string) {
const pager = pagerRef.current;
const lane = pager?.querySelector<HTMLElement>(`[data-category-id="${id}"]`);
if (!pager || !lane) return;
const left = lane.getBoundingClientRect().left - pager.getBoundingClientRect().left + pager.scrollLeft;
pager.scrollTo({ left, behavior: "smooth" });
}
return (
<DndContext
id="board-dnd"
@ -136,17 +194,22 @@ function Board({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex h-full flex-col gap-3 p-3 md:gap-4 md:p-4">
<div
data-board-chrome=""
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1"
>
<div className="flex min-w-0 items-center gap-3">
<span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
<span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary md:flex">
<LayoutDashboard className="size-5" />
</span>
<div className="min-w-0">
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate font-heading text-lg font-bold leading-tight md:text-xl">{title}</h1>
{/* Per-project theme picker, to the right of the project's
name -- only projects have their own theme assignment. */}
{projectId && <ProjectThemePicker projectId={projectId} />}
</div>
<p className="truncate text-[13px] text-muted-foreground">
{categories.length === 0
? "Add a lane to get started"
@ -173,9 +236,10 @@ function Board({
className="gap-1.5"
disabled={!quickTargetGroup}
onClick={() => setQuickAddOpen(true)}
aria-label="Add to-do"
>
<Plus className="size-3.5" />
To-do
<span className="hidden md:inline">To-do</span>
</Button>
}
/>
@ -188,19 +252,37 @@ function Board({
</div>
{categories.length === 0 ? (
<div className="flex flex-1 items-center gap-4 overflow-x-auto">
<div className="flex flex-1 flex-col items-stretch gap-4 overflow-y-auto md:flex-row md:items-center md:overflow-x-auto">
<EmptyState />
<AddCategoryLane />
</div>
) : (
<div className="flex flex-1 items-start gap-4 overflow-x-auto pb-2">
<SortableContext items={categoryIds} strategy={horizontalListSortingStrategy}>
{categories.map((category) => (
<CategoryLane key={category.id} category={category} />
))}
</SortableContext>
<AddCategoryLane />
</div>
<>
{/* Mobile only: the category rail. On desktop the lanes are
already labeled and side-by-side, so this stays hidden. */}
<div className="md:hidden">
<CategoryChips
categories={categories}
activeCategoryId={activeCategoryId ?? categories[0]?.id ?? null}
onSelect={handleSelectChip}
/>
</div>
{/* Mobile: one lane per screen, snap-paged (swipe = next lane),
while each lane still scrolls its cards vertically. Desktop
keeps the plain side-by-side scroll (snap disabled at md). */}
<div
ref={pagerRef}
className="flex min-h-0 flex-1 items-start gap-4 overflow-x-auto pb-2 snap-x snap-mandatory overscroll-x-contain md:snap-none"
>
<SortableContext items={categoryIds} strategy={horizontalListSortingStrategy}>
{categories.map((category) => (
<CategoryLane key={category.id} category={category} />
))}
</SortableContext>
<AddCategoryLane />
</div>
</>
)}
</div>

View File

@ -151,9 +151,9 @@ export function SummaryButton({ projectId }: { projectId?: string }) {
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="gap-1.5">
<Button variant="outline" size="sm" className="gap-1.5" aria-label="Summarize completed work">
<FileText className="size-3.5" />
Summary
<span className="hidden md:inline">Summary</span>
</Button>
}
/>

View File

@ -40,7 +40,20 @@ export function TodoProgressPie({
// context (see hover:-translate-y-0.5 in GroupCard), so
// without an explicit z-index here it paints on top and crops
// the badge's overlapping edge.
className="absolute -right-1.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
// -right-2.5: the badge's horizontal position is a budget, not
// a taste call. From the card's right border there are exactly
// 10px of lane (CategoryLane's px-2.5) before the lane's own
// clip edge -- or its vertical scrollbar, in classic-scrollbar
// browsers, which sits at the same 10px mark and paints over
// anything past it. So the badge may stick out at most ~7px
// (10px offset from the card's *padding* edge minus the 3px
// border) to stay clear of both. In exchange the "N/N done"
// footer text carries pr-2.5, which hands this badge the room
// it needs to clear the text (see the span in GroupCard).
// Moving it further out looks like it "fits" until it doesn't:
// past the 10px mark the lane clips the badge's edge or grows
// a stray horizontal scrollbar.
className="absolute -right-2.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{
borderColor: accentColor,
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,

View File

@ -28,9 +28,9 @@ export function ViewSwitcher() {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground">
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground" aria-label={`Board view: ${current.label}`}>
<Icon className="size-4" />
{current.label}
<span className="hidden md:inline">{current.label}</span>
</Button>
}
/>

View File

@ -26,9 +26,20 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
// safely share one instance without knowing about each other.
//
// A hold can also be frozen mid-flight (pauseHold) and later resumed
// (beginHold) or dropped (cancelHold) -- e.g. to keep a brand-new group on
// screen for as long as its "add to-do" window is still open, however long
// that takes.
const HOLD_MS = 6_000;
const FADE_MS = 1_000;
const COLLAPSE_MS = 200;
// The one-off grace period a brand-new (necessarily empty) group gets in
// compact view -- without it, compact's "hide empty groups" rule would drop
// it before it was ever seen, so this is its "moment to be noticed / have a
// to-do added" window. Longer than the usual hold since there's no
// accidental click to forgive here.
export const NEW_GROUP_HOLD_MS = 15_000;
export type HoldPhase = "visible" | "fading" | "collapsing";
@ -39,6 +50,12 @@ interface HoldOnCompleteContextValue {
// override just the "visible" stage's length.
beginHold: (id: string, holdMs?: number) => void;
cancelHold: (id: string) => void;
// Freeze a live hold in place: clears its pending stage timers and
// (re)sets it to full "visible" with nothing scheduled after, so the item
// stays on screen until beginHold (resume the staged fade-out) or
// cancelHold (drop it) is called for it again. No-op if the id has no
// live hold.
pauseHold: (id: string) => void;
}
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
@ -86,6 +103,25 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
[clearTimers, setPhase]
);
// Freeze an existing hold: the item snaps back (and stays) at full
// "visible" opacity with no stage timers pending, so it lingers on screen
// indefinitely -- call beginHold to resume the staged fade-out, or
// cancelHold to drop the hold outright.
const pauseHold = useCallback(
(id: string) => {
clearTimers(id);
setHolds((prev) => {
// Pausing is only meaningful for an id that's already mid-hold --
// don't invent a hold for one that isn't.
if (!prev.has(id)) return prev;
const next = new Map(prev);
next.set(id, "visible");
return next;
});
},
[clearTimers]
);
// Belt-and-suspenders: drop any still-pending timers on unmount so they
// don't fire setState against a gone provider.
useEffect(() => {
@ -96,7 +132,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
}, []);
return (
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
{children}
</HoldOnCompleteContext.Provider>
);

View File

@ -0,0 +1,36 @@
"use client";
import { Menu, NotebookPen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { useSideNav } from "@/components/nav/side-nav-provider";
/**
* Mobile (< md) app top bar: hamburger (opens the nav drawer) + brand,
* with the theme picker on the right so it's reachable without opening
* the drawer. The page's own header (board title, counts, actions) sits
* in the content area just below this bar.
*/
export function MobileTopBar() {
const { setMobileOpen } = useSideNav();
return (
<header className="flex h-14 shrink-0 items-center gap-1.5 border-b bg-background px-2.5 md:hidden">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="Open menu">
<Menu className="size-5" />
</Button>
<div className="flex min-w-0 items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<NotebookPen className="size-4" />
</span>
<span className="truncate font-heading text-[15px] font-bold tracking-tight">Organize</span>
</div>
<div className="ml-auto flex shrink-0 items-center">
<ThemeToggle collapsed menuSide="bottom" menuAlign="end" />
</div>
</header>
);
}

View File

@ -7,6 +7,10 @@ const STORAGE_KEY = "organize:sidenav-collapsed";
interface SideNavContextValue {
collapsed: boolean;
toggle: () => void;
// The mobile (< md) hamburger drawer. Deliberately NOT persisted: a drawer
// should never be left open across a reload or navigation.
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
}
const SideNavContext = createContext<SideNavContextValue | null>(null);
@ -16,6 +20,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) {
// hydration mismatch; the real persisted value is applied right after
// mount, trading a one-frame flash for zero hydration warnings.
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
@ -31,7 +36,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) {
};
return (
<SideNavContext.Provider value={{ collapsed, toggle }}>
<SideNavContext.Provider value={{ collapsed, toggle, mobileOpen, setMobileOpen }}>
{children}
</SideNavContext.Provider>
);

View File

@ -1,6 +1,8 @@
"use client";
import Link from "next/link";
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@ -13,17 +15,72 @@ import { ThemeToggle } from "@/components/theme/theme-toggle";
import { logout } from "@/lib/actions/auth";
import { Role } from "@/lib/generated/prisma/enums";
export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
const { collapsed, toggle } = useSideNav();
/**
* The signed-in user's circle at the bottom of the nav: their photo when
* they've set one, otherwise the letter fallback -- and a link to the
* Profile page either way.
*/
function UserAvatar({
userName,
userEmail,
avatar,
sizeClass,
}: {
userName: string | null;
userEmail: string;
avatar: string | null;
sizeClass: string;
}) {
if (avatar) {
return (
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app
<img
src={avatar}
alt={userName ?? userEmail}
className={`${sizeClass} shrink-0 rounded-full object-cover`}
/>
);
}
const letter = (userName?.trim()[0] ?? userEmail[0] ?? "?").slice(0, 1);
return (
<span
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
>
{letter}
</span>
);
}
/**
* The actual nav content, shared by the desktop aside and the mobile
* hamburger drawer so the two never drift apart. `collapsed` is always
* false in the drawer (a 288px-wide sheet has no room for icon-only mode).
*/
function SideNavContent({
collapsed,
userEmail,
userName,
avatar,
role,
themeMenu,
}: {
collapsed: boolean;
userEmail: string;
userName: string | null;
avatar: string | null;
role: Role;
// Where the theme picker menu opens from its trigger -- right of the
// trigger in the desktop sidebar, below it in the mobile drawer (a
// right-opening menu would run off the phone's right edge there).
themeMenu: { side: "right" | "bottom"; align: "start" | "end" };
}) {
const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
// The user's display label: their name when set, otherwise the email
// (same as before profiles existed).
const userLabel = userName || userEmail;
return (
<aside
className={cn(
"flex h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out",
collapsed ? "w-16" : "w-60"
)}
>
<>
<div className={cn("flex items-center gap-2.5 px-4 py-4", collapsed && "justify-center px-0")}>
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<NotebookPen className="size-4.5" />
@ -46,7 +103,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
</nav>
<div className="space-y-0.5 px-2.5 pb-2">
<ThemeToggle collapsed={collapsed} />
<ThemeToggle collapsed={collapsed} menuSide={themeMenu.side} menuAlign={themeMenu.align} />
{collapsed ? (
<form action={logout}>
@ -72,19 +129,132 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
</div>
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
{(userEmail[0] ?? "?").slice(0, 1)}
</span>
{!collapsed && (
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
{collapsed ? (
<Tooltip>
<TooltipTrigger
render={
<Link href="/profile" aria-label={`View profile (${userLabel})`} />
}
>
<UserAvatar
userName={userName}
userEmail={userEmail}
avatar={avatar}
sizeClass="size-7"
/>
</TooltipTrigger>
<TooltipContent side="right">{userLabel}</TooltipContent>
</Tooltip>
) : (
<Link
href="/profile"
className="flex min-w-0 flex-1 items-center gap-2"
aria-label="View profile"
>
<UserAvatar
userName={userName}
userEmail={userEmail}
avatar={avatar}
sizeClass="size-7"
/>
<span className="truncate text-[13px] font-medium text-muted-foreground">
{userLabel}
</span>
</Link>
)}
</div>
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
</Button>
</div>
</aside>
</>
);
}
/**
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
* Tapping any link closes it; the backdrop and Escape close it too.
*/
function MobileNavDrawer({
userEmail,
userName,
avatar,
role,
}: {
userEmail: string;
userName: string | null;
avatar: string | null;
role: Role;
}) {
const { mobileOpen, setMobileOpen } = useSideNav();
return (
<DialogPrimitive.Root open={mobileOpen} onOpenChange={setMobileOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-40 bg-black/60 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 md:hidden"
/>
<DialogPrimitive.Popup
onClick={(e) => {
// Close on navigation -- links (nav items, projects) are the only
// things that should dismiss it, not e.g. a theme switch.
if ((e.target as HTMLElement).closest("a")) setMobileOpen(false);
}}
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col overflow-y-auto bg-sidebar text-sidebar-foreground shadow-xl duration-200 md:hidden",
"data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-left",
"data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-left"
)}
>
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
<SideNavContent
collapsed={false}
userEmail={userEmail}
userName={userName}
avatar={avatar}
role={role}
themeMenu={{ side: "bottom", align: "end" }}
/>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
export function SideNav({
userEmail,
userName,
avatar,
role,
}: {
userEmail: string;
userName: string | null;
avatar: string | null;
role: Role;
}) {
const { collapsed, toggle } = useSideNav();
return (
<>
<aside
className={cn(
"hidden h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out md:flex",
collapsed ? "w-16" : "w-60"
)}
>
<SideNavContent
collapsed={collapsed}
userEmail={userEmail}
userName={userName}
avatar={avatar}
role={role}
themeMenu={{ side: "right", align: "start" }}
/>
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
</Button>
</div>
</aside>
<MobileNavDrawer userEmail={userEmail} userName={userName} avatar={avatar} role={role} />
</>
);
}

View File

@ -0,0 +1,33 @@
import type { LinkAccountIdentity } from "@/types/profile";
/**
* Photo-or-initial circle for another account (Linked Accounts, pending
* requests, blocked requests). Same look as the signed-in user's avatar in
* the side nav so the two read as the same kind of thing.
*/
export function AccountAvatar({
account,
sizeClass,
}: {
account: LinkAccountIdentity;
sizeClass: string;
}) {
if (account.avatar) {
return (
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app
<img
src={account.avatar}
alt={account.name ?? account.email}
className={`${sizeClass} shrink-0 rounded-full object-cover`}
/>
);
}
const letter = (account.name?.trim()[0] ?? account.email[0] ?? "?").toUpperCase();
return (
<span
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
>
{letter}
</span>
);
}

View File

@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AccountAvatar } from "@/components/profile/account-avatar";
import { unblockAccountLinkRequests } from "@/lib/actions/account-links";
import type { BlockedRequesterDTO } from "@/types/profile";
/**
* Accounts this account blocked from sending link requests (via "Deny and
* Block Account Link"). Lifting a block lets that account request again --
* the block is permanent for them otherwise, so it must be reversible
* here.
*/
export function BlockedLinkRequests({ blocks }: { blocks: BlockedRequesterDTO[] }) {
const [busyId, setBusyId] = useState<string | null>(null);
async function handleAllow(block: BlockedRequesterDTO) {
if (busyId) return;
setBusyId(block.blockId);
try {
const result = await unblockAccountLinkRequests(block.blockId);
if (result?.error) {
toast.error(result.error);
return;
}
toast.success(`${block.email} can send link requests to you again.`);
} catch {
toast.error("Couldn't allow requests from that account. Try again.");
} finally {
setBusyId(null);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Blocked Account Link Requests</CardTitle>
<CardDescription>
These accounts can&apos;t send link requests to this one.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{blocks.map((block) => {
const busy = busyId === block.blockId;
return (
<div
key={block.blockId}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<AccountAvatar account={block} sizeClass="size-9" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{block.email}</p>
<p className="truncate text-xs text-muted-foreground">
{block.name ? `${block.name} · ` : ""}
blocked {block.blockedAtLabel}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleAllow(block)}
disabled={busyId !== null}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ShieldCheck className="size-3.5" />
)}
Allow requests
</Button>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ArrowLeftRight, Loader2, Unlink } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import { AccountAvatar } from "@/components/profile/account-avatar";
import { removeAccountLink, toggleAccount } from "@/lib/actions/account-links";
import type { LinkedAccountDTO } from "@/types/profile";
/**
* "Linked Accounts": the other accounts this one is linked to.
* - "Toggle": signs this browser out and back in as that account (the
* server verifies the link and mints a one-time switch token, so the
* other account's password is never needed).
* - "Remove Link": either account in the pair can do it; confirmed first.
*/
export function LinkedAccounts({ accounts }: { accounts: LinkedAccountDTO[] }) {
const [togglingId, setTogglingId] = useState<string | null>(null);
const [removing, setRemoving] = useState<LinkedAccountDTO | null>(null);
const [removingBusy, setRemovingBusy] = useState(false);
async function handleToggle(account: LinkedAccountDTO) {
if (togglingId) return;
setTogglingId(account.id);
try {
const result = await toggleAccount(account.id);
if (result?.url) {
// The action already signed this browser in as the linked account;
// navigate so the app shell re-renders for them.
window.location.href = result.url;
return;
}
toast.error(result?.error ?? "Couldn't switch to that account.");
} catch {
toast.error("Couldn't switch to that account. Try again.");
} finally {
setTogglingId(null);
}
}
async function handleRemove() {
if (!removing) return;
const account = removing;
setRemoving(null);
setRemovingBusy(true);
try {
const result = await removeAccountLink(account.linkId);
if (result?.error) {
toast.error(result.error);
return;
}
toast.success(`Link with ${account.email} removed.`);
} catch {
toast.error("Couldn't remove the link. Try again.");
} finally {
setRemovingBusy(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Linked Accounts</CardTitle>
<CardDescription>
Accounts this one is linked to. Toggle signs you in as that
account; Remove Link works from either side.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{accounts.map((account) => (
<div
key={account.linkId}
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<AccountAvatar account={account} sizeClass="size-9" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{account.email}</p>
{account.name && (
<p className="truncate text-xs text-muted-foreground">{account.name}</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => handleToggle(account)}
disabled={togglingId !== null}
>
{togglingId === account.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ArrowLeftRight className="size-3.5" />
)}
{togglingId === account.id ? "Switching…" : "Toggle"}
</Button>
<Button
variant="outline"
size="sm"
disabled={togglingId !== null}
onClick={() => setRemoving(account)}
>
<Unlink className="size-3.5" />
Remove Link
</Button>
</div>
</div>
))}
</CardContent>
<ConfirmDeleteDialog
open={removing !== null}
onOpenChange={(open) => !removingBusy && !open && setRemoving(null)}
title={`Remove link with ${removing?.email}?`}
description="Either linked account can remove the link. Removing it doesn't block anything -- the other account can still send a new link request, which you can accept again."
confirmLabel="Remove Link"
onConfirm={handleRemove}
/>
</Card>
);
}

View File

@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Check, Loader2, ShieldBan, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import { AccountAvatar } from "@/components/profile/account-avatar";
import {
createAccountLink,
denyAccountLink,
denyAndBlockAccountLink,
} from "@/lib/actions/account-links";
import type { PendingLinkRequestDTO } from "@/types/profile";
type DenyIntent = { request: PendingLinkRequestDTO; block: boolean };
/**
* "Requested Account Link" section: link requests this account received.
* Only the recipient acts on these -- the requester just sees
* "awaiting confirmation" on their own Profile page.
*/
export function PendingLinkRequests({ requests }: { requests: PendingLinkRequestDTO[] }) {
// Which request row (if any) is currently processing a button press.
const [busyRequestId, setBusyRequestId] = useState<string | null>(null);
const [denyIntent, setDenyIntent] = useState<DenyIntent | null>(null);
/** Runs a request action, toasting its error (if any) and reporting
* success so the caller can toast a specific confirmation. */
async function run(requestId: string, action: () => Promise<{ error?: string } | undefined>) {
setBusyRequestId(requestId);
let failed = false;
try {
const result = await action();
if (result?.error) {
toast.error(result.error);
failed = true;
}
} catch {
toast.error("That didn't work. Try again.");
failed = true;
} finally {
setBusyRequestId(null);
}
return !failed;
}
async function handleConfirmLink(request: PendingLinkRequestDTO) {
const ok = await run(request.requestId, () => createAccountLink(request.requestId));
if (ok) toast.success("Account link created.");
}
async function handleDeny() {
if (!denyIntent) return;
const { request, block } = denyIntent;
setDenyIntent(null);
const ok = await run(
request.requestId,
block
? () => denyAndBlockAccountLink(request.requestId)
: () => denyAccountLink(request.requestId)
);
if (!ok) return;
toast.success(
block
? "Request denied — that account can't send link requests to you anymore."
: "Request denied."
);
}
return (
<Card>
<CardHeader>
<CardTitle>Requested Account Link</CardTitle>
<CardDescription>
These accounts asked to link with this one. Linking lets each
account toggle the other in.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{requests.map((request) => {
const busy = busyRequestId === request.requestId;
return (
<div
key={request.requestId}
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<AccountAvatar account={request} sizeClass="size-9" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{request.email}</p>
<p className="truncate text-xs text-muted-foreground">
{request.name ? `${request.name} · ` : ""}
requested {request.requestedAtLabel}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => handleConfirmLink(request)}
disabled={busyRequestId !== null}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
Create Account Link
</Button>
<Button
variant="outline"
size="sm"
disabled={busyRequestId !== null}
onClick={() => setDenyIntent({ request, block: false })}
>
<X className="size-3.5" />
Deny Account Link
</Button>
<Button
variant="destructive"
size="sm"
disabled={busyRequestId !== null}
onClick={() => setDenyIntent({ request, block: true })}
>
<ShieldBan className="size-3.5" />
Deny and Block Account Link
</Button>
</div>
</div>
);
})}
</CardContent>
<ConfirmDeleteDialog
open={denyIntent !== null}
onOpenChange={(open) => !open && setDenyIntent(null)}
title={denyIntent?.block ? "Deny and block this account?" : "Deny this request?"}
description={
denyIntent?.block
? `Deny the link request from ${denyIntent?.request?.email} and block that account from sending any more link requests to this one. You can lift the block later from the Blocked Account Link Requests section.`
: `Deny the link request from ${denyIntent?.request?.email}. They can request again later unless you block them.`
}
confirmLabel={denyIntent?.block ? "Deny and Block" : "Deny"}
onConfirm={handleDeny}
/>
</Card>
);
}

View File

@ -0,0 +1,211 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Camera, Loader2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
removeAvatar as removeAvatarAction,
updateProfileNames,
uploadAvatar as uploadAvatarAction,
} from "@/lib/actions/profile";
import type { ProfileDTO } from "@/types/profile";
// Kept in sync with lib/actions/profile.ts (the server re-checks both).
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
const ACCEPTED_AVATAR_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
];
export function ProfileForm({ initial }: { initial: ProfileDTO }) {
const [firstName, setFirstName] = useState(initial.firstName ?? "");
const [lastName, setLastName] = useState(initial.lastName ?? "");
// What's currently saved in the DB -- the "Save" button is only enabled
// while the inputs differ from this.
const [savedNames, setSavedNames] = useState({
firstName: initial.firstName ?? "",
lastName: initial.lastName ?? "",
});
const [avatar, setAvatar] = useState<string | null>(initial.avatar);
const [savingNames, setSavingNames] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const namesDirty =
firstName.trim() !== savedNames.firstName ||
lastName.trim() !== savedNames.lastName;
async function handleSaveNames() {
setSavingNames(true);
try {
await updateProfileNames(firstName, lastName);
setSavedNames({ firstName, lastName });
toast.success("Name saved.");
} catch {
toast.error("Couldn't save your name. Try again.");
} finally {
setSavingNames(false);
}
}
async function handleFileChosen(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = ""; // allow re-picking the same file
if (!file) return;
// Same rules as the server action -- check early so a bad file never
// leaves the browser.
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
toast.error("Please choose a JPEG, PNG, WebP, or GIF image.");
return;
}
if (file.size > MAX_AVATAR_BYTES) {
toast.error("Image must be 10 MB or smaller.");
return;
}
setUploading(true);
try {
const result = await uploadAvatarAction(file);
if (result.error) {
toast.error(result.error);
return;
}
setAvatar(result.avatar ?? null);
toast.success("Profile photo updated.");
} catch (error) {
// A thrown (rather than returned) error is a framework-level
// rejection -- typically Next.js' server-action body limit 413ing
// the file before it reaches the action. Say so instead of the
// generic "try another image", which is misleading here.
if (error instanceof Error && /body exceeded|413/i.test(error.message)) {
toast.error(
"Image is too large for the server to accept. Try one under 10 MB."
);
} else {
toast.error("Couldn't upload that photo. Try again.");
}
} finally {
setUploading(false);
}
}
async function handleRemoveAvatar() {
setUploading(true);
try {
await removeAvatarAction();
setAvatar(null);
toast.success("Profile photo removed.");
} catch {
toast.error("Couldn't remove the photo. Try again.");
} finally {
setUploading(false);
}
}
const initials = (firstName.trim()[0] ?? lastName.trim()[0] ?? "?").toUpperCase();
return (
<Card className="max-w-xl">
<CardContent className="space-y-5">
{/* Photo */}
<div className="flex items-center gap-4">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app
<img
src={avatar}
alt="Profile"
className="size-16 shrink-0 rounded-full object-cover"
/>
) : (
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-bold text-primary">
{initials}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_AVATAR_TYPES.join(",")}
className="hidden"
onChange={handleFileChosen}
disabled={uploading}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Camera className="size-3.5" />
)}
{uploading ? "Updating…" : avatar ? "Change photo" : "Add photo"}
</Button>
{avatar && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveAvatar}
disabled={uploading}
>
<Trash2 className="size-3.5" />
Remove
</Button>
)}
</div>
</div>
{/* Name */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-first-name">First name</Label>
<Input
id="profile-first-name"
value={firstName}
maxLength={60}
autoComplete="given-name"
placeholder="Alex"
onChange={(e) => setFirstName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-last-name">Last name</Label>
<Input
id="profile-last-name"
value={lastName}
maxLength={60}
autoComplete="family-name"
placeholder="Fertig"
onChange={(e) => setLastName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSaveNames} disabled={!namesDirty || savingNames}>
{savingNames ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Saving
</>
) : (
"Save name"
)}
</Button>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Clock, Link2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { requestAccountLink } from "@/lib/actions/account-links";
/**
* "Request Account Link": asks to link this account with another one on
* the server by email. On success the form is replaced by the
* "awaiting confirmation" status, since the recipient still has to accept
* it from their own Profile page.
*/
export function RequestLinkForm() {
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
// Set once a request goes out; the section then shows the awaiting
// confirmation status until the recipient responds (or it's denied).
const [requestedEmail, setRequestedEmail] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitting) return;
setSubmitting(true);
try {
const result = await requestAccountLink(email);
if (result?.error) {
toast.error(result.error);
return;
}
setRequestedEmail(email.trim().toLowerCase());
toast.success("Link request sent.");
} catch {
toast.error("Couldn't send the link request. Try again.");
} finally {
setSubmitting(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Request Account Link</CardTitle>
<CardDescription>
Link this account with another account on this server so you can
toggle between them. The other account has to accept the request.
</CardDescription>
</CardHeader>
<CardContent>
{requestedEmail ? (
<div className="flex items-center gap-2 text-sm">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span>
Request sent to{" "}
<span className="font-medium">{requestedEmail}</span> awaiting
confirmation.
</span>
</div>
) : (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-3 sm:flex-row sm:items-end"
>
<div className="flex-1 space-y-1.5">
<Label htmlFor="link-request-email">Account email</Label>
<Input
id="link-request-email"
type="email"
required
autoComplete="off"
spellCheck={false}
placeholder="you@example.com"
value={email}
disabled={submitting}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Button type="submit" disabled={submitting || !email.trim()}>
{submitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Link2 className="size-3.5" />
)}
{submitting ? "Requesting…" : "Request Account Link"}
</Button>
</form>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,145 @@
"use client";
import { Check, ChevronDown, Monitor, Palette } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useProjects } from "@/components/projects/projects-context";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
LIGHT_OPTIONS,
DARK_OPTIONS,
type ThemeOption,
} from "@/components/theme/theme-toggle";
import { useTheme, type ThemeName } from "@/components/theme/theme-provider";
const DEFAULT_LABEL = "Default theme";
/** One named-theme row. Uses DropdownMenuItem (a real menu item) so the
* menu closes on selection, matching the app's other menus. */
function ThemeOptionRow({
option,
active,
onSelect,
}: {
option: ThemeOption;
active: boolean;
onSelect: () => void;
}) {
const Icon: LucideIcon = option.icon;
return (
<DropdownMenuItem
onClick={onSelect}
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
>
<Icon className="size-4 text-muted-foreground" />
<span>{option.label}</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
{active && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
);
}
/**
* The per-project theme picker, rendered to the right of a project's name
* (see components/board/kanban-board.tsx).
*
* "Default theme" (project.theme = null) means "whatever the user assigned
* in the global theme menu" -- the same value this picker's checkmark and
* label track, live. Picking a named theme stores it on the project; the
* project page then scopes the app to that theme while it's open
* (components/theme/project-theme-scope.tsx).
*/
export function ProjectThemePicker({ projectId }: { projectId: string }) {
const { projects, setProjectTheme } = useProjects();
const { resolvedTheme } = useTheme();
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
const theme: ThemeName | null = project.theme;
const activeOption = theme
? [...LIGHT_OPTIONS, ...DARK_OPTIONS].find((o) => o.value === theme)
: undefined;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 rounded-lg px-2 text-[13px] text-muted-foreground hover:text-foreground"
aria-label="Choose this project's theme"
>
<Palette className="size-3.5" />
<span className="max-w-36 truncate">
{theme ? activeOption?.label ?? DEFAULT_LABEL : DEFAULT_LABEL}
</span>
<ChevronDown className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuItem
onClick={() => setProjectTheme(projectId, null)}
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
>
<Monitor className="size-4 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate">Default theme</span>
<span className="block truncate text-xs text-muted-foreground">
Follows the global theme menu
</span>
</span>
<span
className="ml-auto size-3 shrink-0 rounded-full ring-1 ring-foreground/15"
style={{
background: "linear-gradient(90deg, #4f56e0 50%, #7c83f2 50%)",
}}
aria-hidden
/>
{theme === null && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Light</DropdownMenuLabel>
{LIGHT_OPTIONS.map((option) => (
<ThemeOptionRow
key={option.value}
option={option}
active={theme === option.value}
onSelect={() => setProjectTheme(projectId, option.value)}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Dark</DropdownMenuLabel>
{DARK_OPTIONS.map((option) => (
<ThemeOptionRow
key={option.value}
option={option}
active={theme === option.value}
onSelect={() => setProjectTheme(projectId, option.value)}
/>
))}
<DropdownMenuSeparator />
<p className="px-2 py-1.5 text-xs leading-snug text-muted-foreground">
{theme === null
? `Currently following your global theme (now: ${
resolvedTheme.charAt(0).toUpperCase() + resolvedTheme.slice(1)
}).`
: `This theme applies only while "${project.title}" is open.`}
</p>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -3,11 +3,13 @@
import { createContext, useContext, useState, useCallback } from "react";
import { toast } from "sonner";
import type { ThemeName } from "@/lib/themes";
import type { ProjectDTO } from "@/types/project";
import {
createProject,
renameProject as renameProjectAction,
deleteProject as deleteProjectAction,
setProjectTheme as setProjectThemeAction,
} from "@/lib/actions/projects";
interface ProjectsContextValue {
@ -15,6 +17,10 @@ interface ProjectsContextValue {
addProject: (title: string) => Promise<ProjectDTO | undefined>;
renameProject: (projectId: string, title: string) => Promise<void>;
removeProject: (projectId: string) => Promise<boolean>;
/** Assign a project theme, or pass null for "Default Theme" (the user's
* global preference). Optimistic: the new value lands immediately and
* rolls back on failure. */
setProjectTheme: (projectId: string, theme: ThemeName | null) => Promise<void>;
}
const ProjectsContext = createContext<ProjectsContextValue | null>(null);
@ -75,8 +81,27 @@ export function ProjectsProvider({
return true;
}, []);
const setProjectTheme = useCallback(
async (projectId: string, theme: ThemeName | null) => {
let prevState: ProjectDTO[] = [];
setProjects((prev) => {
prevState = prev;
return prev.map((p) => (p.id === projectId ? { ...p, theme } : p));
});
try {
await setProjectThemeAction(projectId, theme);
} catch {
setProjects(prevState);
toast.error("Couldn't save the project theme. Try again.");
}
},
[]
);
return (
<ProjectsContext.Provider value={{ projects, addProject, renameProject, removeProject }}>
<ProjectsContext.Provider
value={{ projects, addProject, renameProject, removeProject, setProjectTheme }}
>
{children}
</ProjectsContext.Provider>
);

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { formatWeekdayDate } from "@/lib/format";
@ -102,6 +103,9 @@ export function ScheduledPanel() {
const [board, setBoard] = useState<ScheduledBoardDTO | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Mobile bottom sheet (the dock bar below opens it). Kept separate from
// `dialogOpen` -- that one is the add/edit form dialog.
const [sheetOpen, setSheetOpen] = useState(false);
const { holds, beginHold, cancelHold } = useHoldOnComplete();
const refresh = useCallback(() => {
@ -203,7 +207,7 @@ export function ScheduledPanel() {
<>
<aside
className={cn(
"hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 sm:flex",
"hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 md:flex",
collapsed ? "w-16" : "w-80"
)}
>
@ -228,6 +232,77 @@ export function ScheduledPanel() {
)}
</aside>
{/* Mobile (< md) entry point: a dock bar that sits at the bottom of
the app column (in-flow, so nothing overlaps it) carrying the
same overdue/today urgency signals as the desktop collapsed
rail. It opens the full panel as a bottom sheet below rather
than dedicating permanent screen width to it. */}
<nav
aria-label="Scheduled to-dos"
className="shrink-0 border-t bg-sidebar text-sidebar-foreground md:hidden"
>
<button
type="button"
onClick={() => setSheetOpen(true)}
className="flex h-14 w-full items-center justify-center gap-2 transition-colors active:bg-accent/60"
>
<CalendarClock className="size-5" />
<span className="text-sm font-semibold">Scheduled</span>
{overdueCount > 0 && (
<span
className="relative flex size-5 items-center justify-center"
aria-label={`${overdueCount} overdue`}
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
{overdueCount > 9 ? "9+" : overdueCount}
</span>
</span>
)}
{todayCount > 0 && (
<span
className="flex size-5 animate-pulse items-center justify-center rounded-full bg-white text-[10px] font-bold text-black shadow-sm ring-1 ring-border"
aria-label={`${todayCount} due today`}
>
{todayCount > 9 ? "9+" : todayCount}
</span>
)}
</button>
{/* Room for the iPhone home indicator so the bar never tucks under it. */}
<div style={{ paddingBottom: "env(safe-area-inset-bottom)" }} />
</nav>
<DialogPrimitive.Root open={sheetOpen} onOpenChange={setSheetOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-40 bg-black/60 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 md:hidden"
/>
<DialogPrimitive.Popup
className={cn(
"fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl bg-popover text-popover-foreground shadow-2xl duration-200 md:hidden",
"data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-bottom",
"data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-bottom"
)}
>
<DialogPrimitive.Title className="sr-only">Scheduled to-dos</DialogPrimitive.Title>
{/* Grabber -- visual affordance that this is a sheet, not the whole screen. */}
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-foreground/20" aria-hidden />
<div className="flex max-h-[85dvh] min-h-0 flex-col">
<ExpandedPanel
board={displayBoard}
onToggleOccurrence={handleToggle}
onEditOccurrence={handleEdit}
onAdd={handleAdd}
onCollapse={() => setSheetOpen(false)}
notificationsSupported={notifications.supported}
notificationsEnabled={notifications.enabled}
onToggleNotifications={handleToggleNotifications}
/>
</div>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<ScheduledTodoDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
@ -414,7 +489,7 @@ function ExpandedPanel({
<Separator />
<div className="flex-1 overflow-y-auto p-3">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{!board ? (
<p className="p-2 text-center text-sm text-muted-foreground">Loading</p>
) : (

View File

@ -0,0 +1,37 @@
"use client";
import { useEffect } from "react";
import { useProjects } from "@/components/projects/projects-context";
import { useThemeScope } from "./theme-provider";
/**
* Temporarily scopes the app's theme (see ThemeProvider in
* theme-provider.tsx) to the project's assigned theme while that project's
* page is on screen. The theme lifts the moment you navigate away, so the
* rest of the app (Home, other projects) keeps the user's global
* preference.
*
* The theme is read from the live ProjectsContext -- not a page prop -- so
* picking a new theme in the picker transforms the page *immediately*,
* without waiting for the server re-render after the action settles.
* `null` (the "Default theme" state) is a no-op: the page follows the
* global theme, and changing the global theme from the theme menu while on
* the page takes effect here too.
*
* The provider never learns the preference from this component (the global
* `theme` state and localStorage are untouched).
*/
export function ProjectThemeScope({ projectId }: { projectId: string }) {
const { projects } = useProjects();
const { setScope } = useThemeScope();
const theme = projects.find((p) => p.id === projectId)?.theme ?? null;
useEffect(() => {
setScope(theme);
return () => setScope(null);
}, [theme, setScope]);
return null;
}

View File

@ -11,6 +11,20 @@ import {
} from "react";
import type { ReactNode } from "react";
import {
THEMES,
THEME_CLASSES,
DARK_SURFACES,
themeColorScheme,
type ThemeName,
type RawTheme,
} from "@/lib/themes";
import { saveUserTheme } from "@/lib/actions/profile";
// Re-exported so existing imports keep working.
export { THEMES, DARK_SURFACES };
export type { ThemeName, RawTheme };
/**
* App theme provider (replaces next-themes).
*
@ -23,73 +37,33 @@ import type { ReactNode } from "react";
*
* Semantics (kept compatible for all existing consumers):
* - `theme` the stored preference: one of THEMES, or "system"
* - `resolvedTheme` the theme actually applied (system resolved via
* prefers-color-scheme), always one of THEMES
* - `resolvedTheme` the theme actually applied, always one of THEMES
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
*
* Theme scoping (per-project themes):
* A subtree can temporarily override the global preference with
* `useThemeScope().setScope(name)` -- the project pages use this so a
* project's assigned theme applies while its page is open and lifts on
* navigation (see components/theme/project-theme-scope.tsx). While a scope
* is active it wins over `theme` for `resolvedTheme` and the DOM; the
* stored global preference is never touched, so changing the global theme
* from the theme menu while inside a scoped project is fine (it applies
* everywhere *except* the scoped page, which keeps its own theme).
* The provider also picks up a `data-project-theme` marker on <html> at
* mount -- set by the project page's no-FOUC inline script -- so a scoped
* theme is in effect from the very first frame of a hard page load.
*
* Per-user themes (per-account, survive account toggles):
* The root layout passes `userTheme` -- the signed-in user's preference
* from the profile row (saveUserTheme action) -- instead of defaulting
* to localStorage. That stored value is the initial state and the
* no-FOUC script applies it before first paint, and setTheme() persists
* changes back to the profile. Anonymous visitors keep the classic
* localStorage-only behavior.
*/
/* Order is the picker order: all the light looks, then the dark looks. */
export const THEMES = [
"default",
"sunset",
"meadow",
"honey",
"rose",
"lavender",
"slate",
"blueprint",
"vaporwave",
"notebook",
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
const STORAGE_KEY = "theme";
const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
meadow: "theme-meadow",
honey: "theme-honey",
rose: "theme-rose",
lavender: "theme-lavender",
slate: "theme-slate",
blueprint: "theme-blueprint",
vaporwave: "theme-vaporwave",
notebook: "theme-notebook",
dark: "dark",
ocean: "ocean",
pine: "theme-pine",
plum: "theme-plum",
midnight: "theme-midnight",
ember: "theme-ember",
rosewood: "theme-rosewood",
cyberpunk: "theme-cyberpunk",
starfield: "theme-starfield",
};
const ALL_CLASSES = Object.values(THEME_CLASSES);
/* Themes whose *surfaces* are dark (see app/globals.css). The single source
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
* color-scheme, and the `dark:` custom-variant in globals.css. */
export const DARK_SURFACES: ThemeName[] = [
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
];
interface ThemeContextValue {
theme: RawTheme;
@ -98,12 +72,21 @@ interface ThemeContextValue {
themes: readonly ThemeName[];
}
interface ThemeScopeValue {
scope: ThemeName | null;
setScope: (scope: ThemeName | null) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "system",
resolvedTheme: "default",
setTheme: () => {},
themes: THEMES,
});
const ThemeScopeContext = createContext<ThemeScopeValue>({
scope: null,
setScope: () => {},
});
function systemPrefersDark(): boolean {
return (
@ -123,54 +106,99 @@ export function resolveTheme(theme: RawTheme): ThemeName {
return resolve(theme);
}
function applyToDom(theme: RawTheme) {
function applyToDom(name: ThemeName) {
if (typeof document === "undefined") return;
const name = resolve(theme);
const root = document.documentElement;
// Also drop "light": a class next-themes applied to <html> in earlier
// versions, harmless but stale.
root.classList.remove(...ALL_CLASSES, "light");
root.classList.add(THEME_CLASSES[name]);
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
root.style.colorScheme = themeColorScheme(name);
}
/** The project page's no-FOUC script leaves this marker behind (see
* themeSwitchScript in lib/themes.ts); read it once at mount so the first
* frame of a hard load is already scoped. */
function readInitialScope(): ThemeName | null {
if (typeof document === "undefined") return null;
const marker = document.documentElement.dataset.projectTheme;
return marker && (THEMES as readonly string[]).includes(marker)
? (marker as ThemeName)
: null;
}
export function ThemeProvider({
children,
defaultTheme = "system",
userTheme,
}: {
children: ReactNode;
defaultTheme?: RawTheme;
/**
* The signed-in user's stored theme preference -- the root layout reads
* it from the profile row (and bakes it into the no-FOUC script). When
* defined it is the source of truth: the provider starts from it instead
* of localStorage, and every setTheme() call is persisted to the profile
* (saveUserTheme) so each linked account keeps its own look after an
* account toggle. When undefined (anonymous visitor) the provider keeps
* the old localStorage-only behavior.
*/
userTheme?: RawTheme;
}) {
// Both the server and the first client render agree on `defaultTheme`
// (hydration-safe); the stored preference is picked up right after mount.
// The inline script in app/layout.tsx already applied the right classes
// before first paint, so there is no visible jump either way.
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
// Both the server and the first client render agree on the initial theme
// (hydration-safe); anonymous users' localStorage preference is picked up
// right after mount. The inline script in app/layout.tsx already applied
// the right classes before first paint, so there is no visible jump
// either way.
const [theme, setThemeState] = useState<RawTheme>(userTheme ?? defaultTheme);
// Starts null (matching the server render); the project-page marker is
// applied in a layout effect below, before paint.
const [scope, setScope] = useState<ThemeName | null>(null);
const resolvedTheme: ThemeName = scope ?? resolve(theme);
useEffect(() => {
// Signed-in users: their profile's stored theme (userTheme) is
// authoritative -- it is already the initial state, and it's what the
// no-FOUC script applied, so localStorage is deliberately not
// consulted (a stale entry from another account must not win).
if (userTheme !== undefined) return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from localStorage (external source), same pattern as the project-theme marker effect below
if (stored) setThemeState(stored as RawTheme);
} catch {
/* storage unavailable -- stay on defaultTheme */
}
}, [userTheme]);
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
// pre-paint window as the apply effect below, so the global-theme flash
// it may cause is never visible. (Mount-time sync from an external
// source, same pattern as the localStorage read above.)
useLayoutEffect(() => {
const initialScope = readInitialScope();
if (initialScope) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from the <html> marker, see above
setScope(initialScope);
}
}, []);
// Keep the DOM in sync whenever the preference changes -- synchronously
// Keep the DOM in sync whenever the resolved theme changes -- synchronously
// (layout effect) so the CSS classes land in the same frame as the
// JS-driven colors that derive from this state: no flash of stale colors.
useLayoutEffect(() => {
applyToDom(theme);
}, [theme]);
applyToDom(resolvedTheme);
}, [resolvedTheme]);
// Follow the OS while in "system" mode.
// Follow the OS while in "system" mode (and nothing is scoped).
useEffect(() => {
if (theme !== "system" || !window.matchMedia) return;
if (scope || theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom("system");
const onChange = () => applyToDom(resolve("system"));
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [theme]);
}, [scope, theme]);
// Keep multiple tabs in sync.
useEffect(() => {
@ -181,23 +209,48 @@ export function ThemeProvider({
return () => window.removeEventListener("storage", onStorage);
}, [defaultTheme]);
const setTheme = useCallback((t: RawTheme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
/* ignore -- preference just won't persist */
}
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
[theme, setTheme],
const setTheme = useCallback(
(t: RawTheme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
/* ignore -- preference just won't persist */
}
// Signed-in: persist to the profile row so the next load -- a hard
// navigation, an account toggle, another device -- applies this
// account's theme. Fire-and-forget: the UI already reflects the
// change; a failed write just means the next load falls back to the
// previously stored value.
if (userTheme !== undefined) {
void saveUserTheme(t).catch(() => {
/* best effort -- in-memory + localStorage state still stands */
});
}
},
[userTheme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
[theme, resolvedTheme, setTheme],
);
const scopeValue = useMemo<ThemeScopeValue>(
() => ({ scope, setScope }),
[scope],
);
return (
<ThemeScopeContext.Provider value={scopeValue}>
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
</ThemeScopeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
export function useThemeScope(): ThemeScopeValue {
return useContext(ThemeScopeContext);
}

View File

@ -31,12 +31,13 @@ import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type ThemeOption = {
export type ThemeOption = {
value: ThemeName;
label: string;
icon: LucideIcon;
@ -45,8 +46,9 @@ type ThemeOption = {
// The looks, grouped by surface. Each value is one next-themes value (see
// app/layout.tsx for the class mapping); the little dot is the theme's own
// accent so the picker previews the vibe.
const LIGHT_OPTIONS: ThemeOption[] = [
// accent so the picker previews the vibe. Exported for reuse by other
// theme pickers (e.g. components/projects/project-theme-picker.tsx).
export const LIGHT_OPTIONS: ThemeOption[] = [
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
{ value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" },
@ -58,7 +60,7 @@ const LIGHT_OPTIONS: ThemeOption[] = [
{ value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" },
{ value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" },
];
const DARK_OPTIONS: ThemeOption[] = [
export const DARK_OPTIONS: ThemeOption[] = [
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
{ value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" },
@ -69,9 +71,9 @@ const DARK_OPTIONS: ThemeOption[] = [
{ value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" },
{ value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" },
];
const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
export const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
function OptionRow({
export function OptionRow({
option,
active,
onSelect,
@ -82,24 +84,34 @@ function OptionRow({
}) {
const Icon = option.icon;
return (
<button
type="button"
<DropdownMenuItem
onClick={onSelect}
className="group relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
>
<Icon className="size-4 text-muted-foreground group-focus:text-current" />
<Icon className="size-4 text-muted-foreground" />
<span>{option.label}</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
{active && <Check className="absolute right-2 -mr-4 size-4" />}
</button>
{active && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
);
}
export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
export function ThemeToggle({
collapsed,
menuSide = "right",
menuAlign = "start",
}: {
collapsed?: boolean;
// Which side of the trigger the picker menu opens on: "right" for the
// sidebar, "bottom" when the trigger sits in the mobile top bar (where a
// right-opening menu would run off the screen).
menuSide?: "right" | "bottom";
menuAlign?: "start" | "end";
}) {
const { theme, setTheme } = useTheme();
// The picker shows all the real themes in Light/Dark sections; "system" is
// handled by a dedicated row since it resolves to one of them per device
@ -123,7 +135,7 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
</Button>
}
/>
<DropdownMenuContent align="start" side="right" className="w-52">
<DropdownMenuContent align={menuAlign} side={menuSide} className="w-52">
<DropdownMenuLabel>Light</DropdownMenuLabel>
{LIGHT_OPTIONS.map((option) => (
<OptionRow
@ -144,10 +156,9 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
/>
))}
<DropdownMenuSeparator />
<button
type="button"
<DropdownMenuItem
onClick={() => setTheme("system")}
className="relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
>
<Monitor className="size-4 text-muted-foreground" />
<span>Match system</span>
@ -158,8 +169,8 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
}}
aria-hidden
/>
{isSystem && <Check className="absolute right-2 -mr-4 size-4" />}
</button>
{isSystem && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useTheme, resolveTheme, DARK_SURFACES } from "./theme-provider";
import { useTheme, DARK_SURFACES } from "./theme-provider";
import { getGroupColor, getThemedGroupColor, type GroupColor } from "@/lib/colors";
// The themes whose *surfaces* are dark (see app/globals.css) come from the
@ -24,9 +24,11 @@ function useMounted(): boolean {
* Midnight, Ember, Rosewood, Cyberpunk).
*
* Reactive to UI theme switches: the value is derived from the provider's
* theme state (the single source of truth), so the moment `setTheme` fires,
* every consumer re-renders with the new variant in the same commit -- no
* stale card colors, no manual refresh needed.
* `resolvedTheme` -- the single source of truth for what's actually
* applied, including a project's scoped theme (see theme-provider) -- so
* the moment `setTheme` fires (or a scope lifts), every consumer
* re-renders with the new variant in the same commit: no stale card
* colors, no manual refresh needed.
*
* Hydration-safe: until mounted we always report "light", matching the
* server render (the no-FOUC script in app/layout.tsx covers the CSS side
@ -34,9 +36,9 @@ function useMounted(): boolean {
*/
export function isDarkTheme(): boolean {
const mounted = useMounted();
const { theme } = useTheme();
const { resolvedTheme } = useTheme();
if (!mounted) return false;
return DARK_SURFACES.includes(resolveTheme(theme));
return DARK_SURFACES.includes(resolvedTheme);
}
/** True when the "cyberpunk" theme is active. Used to swap the neon
@ -44,9 +46,9 @@ export function isDarkTheme(): boolean {
*/
export function isCyberpunkTheme(): boolean {
const mounted = useMounted();
const { theme } = useTheme();
const { resolvedTheme } = useTheme();
if (!mounted) return false;
return resolveTheme(theme) === "cyberpunk";
return resolvedTheme === "cyberpunk";
}
/**
@ -60,9 +62,9 @@ export function isCyberpunkTheme(): boolean {
*/
export function useGroupColor(key: string): GroupColor {
const mounted = useMounted();
const { theme } = useTheme();
const { resolvedTheme } = useTheme();
if (!mounted) return getGroupColor(key);
return getThemedGroupColor(resolveTheme(theme), key);
return getThemedGroupColor(resolvedTheme, key);
}
/**

View File

@ -53,7 +53,12 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm",
// Tall content (edit forms, the AI summary, markdown editors) must
// stay usable on a phone: cap the height and let the dialog scroll
// instead of clipping off-screen.
"max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}

View File

@ -9,6 +9,11 @@ services:
volumes:
# Local filesystem bind mount, not a named Docker volume.
- ./data/postgres:/var/lib/postgresql/data
ports:
# Exposed on the host so host-side tooling (prisma migrate/dev, npm run
# dev with the .env's localhost DATABASE_URL) can reach it. The app
# container still connects over the compose network's "db" hostname.
- "${DB_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s

View File

@ -0,0 +1,299 @@
"use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache";
import { AuthError } from "next-auth";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { EmailSchema } from "@/lib/validation/auth";
import { Prisma } from "@/lib/generated/prisma/client";
import { Role } from "@/lib/generated/prisma/enums";
import { signIn, signOut } from "@/auth";
/** Shared shape for the link-management actions: `error` on failure, a
* plain `{}` (or `ok`) on success. Kept as one flat object -- a discriminated
* union with an index signature confuses React types on the client side. */
export type AccountLinkActionResult = { error?: string; ok?: true };
/** Re-render the Profile page after any change to the linking state.
* (The (app) layout doesn't show link state, so /profile alone suffices.) */
function revalidateProfile() {
revalidatePath("/profile");
}
/** The canonical "does a confirmed link already exist between these two
* accounts?" check -- order-independent, since a link is stored once per
* pair in either direction. */
function linkedPairWhere(userId: string, otherUserId: string) {
return {
OR: [
{ userId, linkedUserId: otherUserId },
{ userId: otherUserId, linkedUserId: userId },
],
};
}
/**
* Sends (or silently refreshes) a link request to another account. The
* recipient sees it in their Profile page's "Requested Account Link"
* section and can create, deny, or deny-and-block it. Returns
* `{ error }` on failure; the UI then shows "awaiting confirmation".
*/
export async function requestAccountLink(email: string): Promise<AccountLinkActionResult> {
const userId = await requireUserId();
const parsed = EmailSchema.safeParse(email);
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? "Enter a valid email address." };
}
const target = await prisma.user.findUnique({ where: { email: parsed.data } });
if (!target) {
return { error: "No account with that email address on this server." };
}
if (target.id === userId) {
return { error: "That's the account you're signed in as." };
}
const existingLink = await prisma.accountLink.findFirst({
where: linkedPairWhere(userId, target.id),
});
if (existingLink) {
return { error: "Those accounts are already linked." };
}
// "Deny and Block Account Link" on their side stops new requests from
// this account to theirs.
const blocked = await prisma.accountLinkBlock.findUnique({
where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } },
});
if (blocked) {
return { error: "That account has blocked link requests from you." };
}
// Upsert, not create: the composite unique makes re-requesting while a
// pending request already exists a no-op refresh rather than a 500.
await prisma.accountLinkRequest.upsert({
where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } },
create: { fromUserId: userId, toUserId: target.id },
update: {},
});
revalidateProfile();
return { ok: true };
}
/**
* The recipient's "Create Account Link": confirms a pending request and
* creates the (single, order-independent) link between the two accounts.
* Also closes any remaining pending requests between the pair, in either
* direction -- once linked, neither side needs to act on them anymore.
*/
export async function createAccountLink(requestId: string): Promise<AccountLinkActionResult> {
const userId = await requireUserId();
const request = await prisma.accountLinkRequest.findUnique({
where: { id: requestId },
});
// Only the *recipient* can confirm; the requester has no buttons here.
if (!request || request.toUserId !== userId) {
return { error: "That link request no longer exists." };
}
const requesterId = request.fromUserId;
if (requesterId === userId) {
return { error: "That link request no longer exists." };
}
try {
await prisma.$transaction([
prisma.accountLink.create({
// Canonical order: the smaller id first. Makes the pair unique
// regardless of which direction the confirming request came in.
data: {
userId: [userId, requesterId].sort()[0],
linkedUserId: [userId, requesterId].sort()[1],
},
}),
prisma.accountLinkRequest.deleteMany({
where: {
OR: [
{ fromUserId: userId, toUserId: requesterId },
{ fromUserId: requesterId, toUserId: userId },
],
},
}),
]);
} catch (error) {
// A concurrent confirm (both sides clicked at once) can win the
// unique pair -- treat as success: the link exists, which is the
// outcome both sides wanted.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { ok: true };
}
throw error;
}
revalidateProfile();
return { ok: true };
}
/**
* The recipient's "Deny Account Link": drops the pending request. The
* requester may try again later (there's no block).
*/
export async function denyAccountLink(requestId: string): Promise<AccountLinkActionResult> {
const userId = await requireUserId();
const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } });
if (!request || request.toUserId !== userId) {
return { error: "That link request no longer exists." };
}
await prisma.accountLinkRequest.delete({ where: { id: requestId } });
revalidateProfile();
return { ok: true };
}
/**
* The recipient's "Deny and Block Account Link": denies the request and
* blocks any future link requests from that account to this one. The
* block shows up in the "Blocked Account Link Requests" section, where it
* can be lifted again.
*/
export async function denyAndBlockAccountLink(requestId: string): Promise<AccountLinkActionResult> {
const userId = await requireUserId();
const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } });
if (!request || request.toUserId !== userId) {
return { error: "That link request no longer exists." };
}
const requesterId = request.fromUserId;
await prisma.$transaction([
prisma.accountLinkRequest.delete({ where: { id: requestId } }),
// Upsert: a block may already exist from an earlier denial -- keep it,
// don't fail on it.
prisma.accountLinkBlock.upsert({
where: { fromUserId_toUserId: { fromUserId: requesterId, toUserId: userId } },
create: { fromUserId: requesterId, toUserId: userId },
update: {},
}),
]);
revalidateProfile();
return { ok: true };
}
/**
* "Remove Link" from the Linked Accounts list: either account in the pair
* can remove the link. Removes only the link itself -- pending requests
* and blocks between the two accounts are left as-is (a re-request simply
* restarts the flow).
*/
export async function removeAccountLink(linkId: string): Promise<AccountLinkActionResult> {
const userId = await requireUserId();
const link = await prisma.accountLink.findUnique({ where: { id: linkId } });
if (!link || (link.userId !== userId && link.linkedUserId !== userId)) {
return { error: "That account link no longer exists." };
}
await prisma.accountLink.delete({ where: { id: linkId } });
revalidateProfile();
return { ok: true };
}
/**
* Lifts a block the signed-in account issued ("Allow requests" in the
* Blocked section), letting that account send link requests again.
*/
export async function unblockAccountLinkRequests(blockId: string): Promise<AccountLinkActionResult> {
const userId = await requireUserId();
const block = await prisma.accountLinkBlock.findUnique({ where: { id: blockId } });
if (!block || block.toUserId !== userId) {
return { error: "That block no longer exists." };
}
await prisma.accountLinkBlock.delete({ where: { id: blockId } });
revalidateProfile();
return { ok: true };
}
/**
* The "Toggle" button: verifies the signed-in user is actually linked to
* the given account, mints a one-time, short-lived token for the
* "account-switch" provider (auth.ts), signs the current session out, and
* signs in as the linked account -- all server-side, so the linked
* account's password is never needed. The client then navigates to the
* returned URL with the new session cookie.
*
* The token rides nowhere the user can craft it: 256 bits of randomness,
* single-use (consumed in the provider's authorize()), and it expires
* within a minute, so a copy in logs or history is worthless.
*/
const SWITCH_TOKEN_TTL_MS = 60_000;
export type ToggleResult = { url?: string; error?: string };
export async function toggleAccount(linkedUserId: string): Promise<ToggleResult> {
const userId = await requireUserId();
if (linkedUserId === userId) {
return { error: "That's the account you're already signed in as." };
}
const link = await prisma.accountLink.findFirst({
where: linkedPairWhere(userId, linkedUserId),
});
if (!link) {
return { error: "Those accounts aren't linked." };
}
// Check before signing the user out: a PENDING (or deleted) target can
// never hold a session (the account-switch provider rejects it), so
// failing here keeps the user signed in to their own account instead of
// stranding them logged out.
const target = await prisma.user.findUnique({
where: { id: linkedUserId },
select: { role: true },
});
if (!target || target.role === Role.PENDING) {
return { error: "That account can't sign in right now -- it may be pending approval." };
}
const token = randomBytes(32).toString("hex");
await prisma.accountSwitchToken.create({
data: {
token,
requestedBy: userId,
targetUserId: linkedUserId,
expiresAt: new Date(Date.now() + SWITCH_TOKEN_TTL_MS),
},
});
// Drop the current session first, then take the linked account's. Both
// responses set the same session cookie, so the second write wins and the
// browser is left exactly one signed-in account -- the toggled-to one.
await signOut({ redirect: false });
try {
// redirect:false: signIn() commits the new session cookie through
// next/headers and returns the redirect URL as a string (see
// next-auth/lib/actions.js) instead of throwing a framework redirect --
// the client does the navigation so it can toast on failure.
const url = await signIn("account-switch", { token, redirectTo: "/", redirect: false });
return { url };
} catch (error) {
// authorize() throws SwitchAccountSignin (an AuthError) for a
// missing/expired/consumed token or a PENDING target account.
if (error instanceof AuthError) {
return { error: "Couldn't switch to that account. Check that it's still linked and active." };
}
throw error;
}
}

27
lib/actions/board.ts Normal file
View File

@ -0,0 +1,27 @@
"use server";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { projectAccessFilter } from "@/lib/access";
import { getBoard } from "@/lib/board";
import type { CategoryDTO } from "@/types/board";
/**
* A fresh read of one board for the client's cross-device sync (see the
* poll in board-context.tsx). Returns the exact same shape the page's
* server component renders, so BoardProvider can diff it against its own
* state and apply it only if something actually changed. `projectId` null
* is Home scope -- same convention as getScheduledBoard.
*/
export async function getBoardSnapshot(projectId: string | null): Promise<CategoryDTO[]> {
const userId = await requireUserId();
if (projectId) {
const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(userId) },
select: { id: true },
});
if (!project) throw new Error("Project not found");
return getBoard({ projectId: project.id });
}
return getBoard({ userId, projectId: null });
}

120
lib/actions/profile.ts Normal file
View File

@ -0,0 +1,120 @@
"use server";
import { revalidatePath } from "next/cache";
import sharp from "sharp";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { ProfileNameSchema, ThemeSchema } from "@/lib/validation/profile";
/**
* Refresh both the Profile page (its form's initial values) and the (app)
* layout (the sidebar shows the user's name/photo) after a change.
* "layout" type invalidates the whole (app) tree -- see
* next/docs revalidatePath "Revalidating a Layout path".
*/
function revalidateProfile() {
revalidatePath("/profile");
revalidatePath("/", "layout");
}
export type ProfileResult = { error?: string };
/**
* Sets the user's first and last name (either may be blank). Blank values
* are stored as null so "cleared" and "never set" mean the same thing.
*/
export async function updateProfileNames(
firstName: string,
lastName: string
): Promise<ProfileResult> {
const userId = await requireUserId();
const parsed = ProfileNameSchema.parse({ firstName, lastName });
await prisma.user.update({
where: { id: userId },
data: {
firstName: parsed.firstName || null,
lastName: parsed.lastName || null,
},
});
revalidateProfile();
return {};
}
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
const ALLOWED_AVATAR_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
// Avatars render at most a couple of dozen CSS pixels; 256x256 covers
// retina without bloat. Re-encoded as JPEG so every row stored in the DB
// has the same shape regardless of the upload's original format.
const AVATAR_SIZE = 256;
export type UploadAvatarResult = { avatar?: string; error?: string };
/**
* Stores the user's profile photo. The upload is resized to 256x256
* (cover crop, EXIF-rotated) and re-encoded as JPEG server-side, then
* saved as a data URL on the User row -- the app's container filesystem
* is ephemeral (only the Postgres volume survives deploys), so the image
* lives in the database rather than on disk.
*/
export async function uploadAvatar(file: File): Promise<UploadAvatarResult> {
const userId = await requireUserId();
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
return { error: "Please choose a JPEG, PNG, WebP, or GIF image." };
}
if (file.size === 0) return { error: "That file is empty." };
if (file.size > MAX_AVATAR_BYTES) {
return { error: "Image must be 10 MB or smaller." };
}
let encoded: Buffer;
try {
encoded = await sharp(Buffer.from(await file.arrayBuffer()))
.rotate() // honor EXIF orientation from phones' photos
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover" })
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return { error: "That doesn't look like a valid image." };
}
const avatar = `data:image/jpeg;base64,${encoded.toString("base64")}`;
await prisma.user.update({ where: { id: userId }, data: { avatar } });
revalidateProfile();
return { avatar };
}
export async function removeAvatar(): Promise<ProfileResult> {
const userId = await requireUserId();
await prisma.user.update({ where: { id: userId }, data: { avatar: null } });
revalidateProfile();
return {};
}
/**
* Saves the user's global theme preference -- the theme menu's setTheme
* (components/theme/theme-provider.tsx) calls this for signed-in users.
* Stored on the profile row so the preference belongs to the *account*,
* not the browser: each account in a linked set keeps its own look after
* an account toggle, and the root layout (app/layout.tsx) applies it from
* the very first frame of the next page load. No revalidation needed: the
* value is read on navigation, which re-renders the layout.
*/
export async function saveUserTheme(theme: string): Promise<ProfileResult> {
const userId = await requireUserId();
const parsed = ThemeSchema.parse(theme);
await prisma.user.update({ where: { id: userId }, data: { theme: parsed } });
return {};
}

View File

@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { projectAccessFilter } from "@/lib/access";
import { ProjectTitleSchema } from "@/lib/validation/project";
import { ProjectTitleSchema, ProjectThemeSchema } from "@/lib/validation/project";
import type { ProjectDTO } from "@/types/project";
export async function createProject(title: string): Promise<ProjectDTO> {
@ -17,7 +17,29 @@ export async function createProject(title: string): Promise<ProjectDTO> {
});
revalidatePath("/projects");
return { id: project.id, title: project.title };
return { id: project.id, title: project.title, theme: null };
}
/**
* Assigns a theme to a project (or clears it back to "Default Theme" with
* null, which follows the user's global theme preference). Only the
* project's own page is themed -- see components/theme/project-theme-scope.tsx.
*/
export async function setProjectTheme(
projectId: string,
theme: string | null
): Promise<void> {
const userId = await requireUserId();
const parsed = ProjectThemeSchema.parse(theme);
const { count } = await prisma.project.updateMany({
where: { id: projectId, ...projectAccessFilter(userId) },
data: { theme: parsed },
});
if (count === 0) throw new Error("Project not found");
revalidatePath("/projects");
revalidatePath(`/projects/${projectId}`);
}
export async function renameProject(projectId: string, title: string): Promise<void> {

View File

@ -14,3 +14,18 @@ import { CredentialsSignin } from "next-auth";
export class PendingAccountSignin extends CredentialsSignin {
code = "pending-account";
}
/**
* Thrown from the "account-switch" provider's authorize() (in auth.ts)
* when the one-time switch token is missing, already used, expired, or
* the target account can't log in (e.g. PENDING). signIn() in the
* toggleAccount action (lib/actions/account-links.ts) then fails with it
* and the Profile page shows an error toast, keeping the user on the
* login flow instead of silently switching accounts.
*
* (Same mechanism as PendingAccountSignin: Auth.js rethrows errors from
* authorize() as-is when signIn() is called from a route/Server Action.)
*/
export class SwitchAccountSignin extends CredentialsSignin {
code = "switch-account";
}

133
lib/themes.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* Isomorphic theme metadata.
*
* Deliberately framework-free: it is imported from server code (project
* theme validation in lib/actions/projects.ts, the no-FOUC script rendered
* by the project page) *and* client code (theme provider, pickers), so it
* must stay free of React/DOM imports. What *applies* themes to the DOM
* lives in components/theme/theme-provider.tsx; the display options
* (labels, icons, swatches) live in components/theme/theme-toggle.tsx.
*/
/* Order is the picker order: all the light looks, then the dark looks. */
export const THEMES = [
"default",
"sunset",
"meadow",
"honey",
"rose",
"lavender",
"slate",
"blueprint",
"vaporwave",
"notebook",
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
/** Theme name -> class applied to <html> (see app/globals.css). */
export const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
meadow: "theme-meadow",
honey: "theme-honey",
rose: "theme-rose",
lavender: "theme-lavender",
slate: "theme-slate",
blueprint: "theme-blueprint",
vaporwave: "theme-vaporwave",
notebook: "theme-notebook",
dark: "dark",
ocean: "ocean",
pine: "theme-pine",
plum: "theme-plum",
midnight: "theme-midnight",
ember: "theme-ember",
rosewood: "theme-rosewood",
cyberpunk: "theme-cyberpunk",
starfield: "theme-starfield",
};
/** Themes whose *surfaces* are dark (see app/globals.css). The single source
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
* color-scheme, and the `dark:` custom-variant in globals.css. */
export const DARK_SURFACES: ThemeName[] = [
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
];
export function themeColorScheme(name: ThemeName): "light" | "dark" {
return DARK_SURFACES.includes(name) ? "dark" : "light";
}
/**
* Source of the root layout's no-FOUC theme script (app/layout.tsx renders
* it as plain server HTML so it runs before first paint).
*
* `serverTheme` is the signed-in user's stored preference (the layout
* reads it from the profile row). When present it is applied verbatim and
* localStorage is *not* consulted -- that is what lets each account in a
* linked set keep its own look in the same browser after an account
* toggle. When absent (anonymous visitor) the script keeps the old
* behavior: restore the browser's localStorage preference, falling back
* to the OS light/dark setting.
*/
export function themeInitScript(serverTheme?: RawTheme | null): string {
const allClasses = JSON.stringify([...Object.values(THEME_CLASSES), "light"]);
const classMap = JSON.stringify(THEME_CLASSES);
const dark = JSON.stringify(DARK_SURFACES);
const system =
"(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'default'";
const apply =
`r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';`;
if (serverTheme != null) {
return (
`try{var r=document.documentElement,c=${allClasses};` +
`for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}` +
`var v=${JSON.stringify(serverTheme)};if(v==='system'){v=${system};}` +
`var m=${classMap},d=${dark};if(m[v]){${apply}}catch(e){}`
);
}
return (
`try{var r=document.documentElement,c=${allClasses};` +
`for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}` +
`var v=null;try{v=localStorage.getItem('theme');}catch(e){}` +
`if(v==='system'||!v){v=${system};}` +
`var m=${classMap},d=${dark};if(m[v]){${apply}}catch(e){}`
);
}
/**
* Inline-script source that switches <html> over to `theme` immediately
* (before first paint), the same way the global no-FOUC script in
* app/layout.tsx works, and leaves a `data-project-theme` marker behind so
* the ThemeProvider can pick the theme up as its initial scope at
* hydration. Render it from a server component with
* `dangerouslySetInnerHTML`.
*/
export function themeSwitchScript(theme: ThemeName): string {
const classes = JSON.stringify([
...Object.values(THEME_CLASSES),
"light",
]);
return `try{var r=document.documentElement;var c=${classes};for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}r.classList.add(${JSON.stringify(
THEME_CLASSES[theme]
)});r.style.colorScheme=${JSON.stringify(
themeColorScheme(theme)
)};r.dataset.projectTheme=${JSON.stringify(theme)};}catch(e){}`;
}

19
lib/validation/profile.ts Normal file
View File

@ -0,0 +1,19 @@
import { z } from "zod";
import { THEMES } from "@/lib/themes";
const NamePartSchema = z.string().trim().max(60, "Must be 60 characters or fewer");
export const ProfileNameSchema = z.object({
firstName: NamePartSchema,
lastName: NamePartSchema,
});
export type ProfileName = z.infer<typeof ProfileNameSchema>;
// The global theme preference stored on the profile row (User.theme):
// one of the real themes, or "system" = follow the OS light/dark setting.
// Written only by saveUserTheme (lib/actions/profile.ts), read by the
// root layout (app/layout.tsx) and the theme provider -- the cast in the
// latter two places is safe because of this schema.
export const ThemeSchema = z.enum([...THEMES, "system"]);
export type ThemePreference = z.infer<typeof ThemeSchema>;

View File

@ -1,3 +1,10 @@
import { z } from "zod";
import { THEMES } from "@/lib/themes";
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60);
/** A project's assigned theme: one of the known themes, or null for
* "Default Theme" (follow the user's global theme preference). */
export const ProjectThemeSchema = z.union([z.enum(THEMES), z.null()]);
export type ProjectTheme = z.infer<typeof ProjectThemeSchema>;

View File

@ -7,6 +7,15 @@ const nextConfig: NextConfig = {
// container start, not imported by app code). The Dockerfile instead
// copies the full `node_modules` into the runtime image, which is a
// simpler and more reliable trade for a single self-hosted instance.
experimental: {
serverActions: {
// Default is 1 MB, which silently 413s profile-photo uploads larger
// than that before the action even runs (the client only sees a
// generic failure). Allow the profile action's 10 MB max file plus
// room for multipart overhead; other actions send tiny bodies.
bodySizeLimit: "11mb",
},
},
};
export default nextConfig;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "theme" TEXT;

View File

@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avatar" TEXT,
ADD COLUMN "firstName" TEXT,
ADD COLUMN "lastName" TEXT;
-- Backfill: existing accounts only have the single `name` captured at
-- sign-up. Split it into firstName (first word) and lastName (the rest) so
-- the Profile page starts from what the user already gave us instead of a
-- blank form. Single-word names (e.g. "Cher") get firstName only.
UPDATE "User"
SET "firstName" = split_part("name", ' ', 1)
WHERE "name" IS NOT NULL AND btrim("name") <> '';
UPDATE "User"
SET "lastName" = trim(regexp_replace("name", '^\S+\s+', ''))
WHERE "name" IS NOT NULL AND "name" ~ '^\S+\s+\S+';

View File

@ -0,0 +1,85 @@
-- CreateTable
CREATE TABLE "AccountLinkRequest" (
"id" TEXT NOT NULL,
"fromUserId" TEXT NOT NULL,
"toUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccountLinkRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountLink" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"linkedUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccountLink_pkey" PRIMARY KEY ("id")
);
-- A link is between two *different* accounts; the app layer also refuses
-- self-links before ever reaching this constraint.
ALTER TABLE "AccountLink" ADD CONSTRAINT "account_link_not_self" CHECK ("userId" <> "linkedUserId");
-- CreateTable
CREATE TABLE "AccountLinkBlock" (
"id" TEXT NOT NULL,
"fromUserId" TEXT NOT NULL,
"toUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccountLinkBlock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountSwitchToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"requestedBy" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccountSwitchToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AccountLinkRequest_fromUserId_toUserId_unique" ON "AccountLinkRequest"("fromUserId", "toUserId");
-- CreateIndex
CREATE INDEX "AccountLinkRequest_toUserId_idx" ON "AccountLinkRequest"("toUserId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountLink_userId_linkedUserId_unique" ON "AccountLink"("userId", "linkedUserId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountLinkBlock_fromUserId_toUserId_unique" ON "AccountLinkBlock"("fromUserId", "toUserId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountSwitchToken_token_key" ON "AccountSwitchToken"("token");
-- AddForeignKey
ALTER TABLE "AccountLinkRequest" ADD CONSTRAINT "AccountLinkRequest_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountLinkRequest" ADD CONSTRAINT "AccountLinkRequest_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountLink" ADD CONSTRAINT "AccountLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountLink" ADD CONSTRAINT "AccountLink_linkedUserId_fkey" FOREIGN KEY ("linkedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountLinkBlock" ADD CONSTRAINT "AccountLinkBlock_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountLinkBlock" ADD CONSTRAINT "AccountLinkBlock_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountSwitchToken" ADD CONSTRAINT "AccountSwitchToken_requestedBy_fkey" FOREIGN KEY ("requestedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountSwitchToken" ADD CONSTRAINT "AccountSwitchToken_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "theme" TEXT;

View File

@ -34,7 +34,25 @@ model User {
id String @id @default(cuid())
email String @unique
passwordHash String
// Legacy single name field, captured at sign-up. Display and the Admin
// page still use it; the Profile page manages firstName/lastName below.
name String?
// Profile page fields. Null = the user hasn't set them.
firstName String?
lastName String?
// Profile photo, stored as a data URL (base64 JPEG) rather than a file on
// disk: this app's container filesystem is ephemeral (only the Postgres
// volume persists across deploys), so the DB is the one place it can live.
// Always server-generated from the user's upload (resized to 256x256 via
// sharp in lib/actions/profile.ts), never stored as the raw upload.
avatar String?
// Global theme preference: one of lib/themes' THEMES, or "system" (follow
// the OS light/dark setting). Null = never set -- the root layout treats
// that as "system". Stored per account (not just in localStorage) so each
// account in a linked set keeps its own look when the browser toggles
// between them; the theme menu persists it via saveUserTheme
// (lib/actions/profile.ts) and app/layout.tsx applies it on every load.
theme String?
// The very first person to sign up becomes ADMIN regardless of
// signupMode (the site needs at least one admin to bootstrap). Everyone
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
@ -46,6 +64,104 @@ model User {
categories Category[]
projects Project[] @relation("ProjectOwner")
scheduledTodos ScheduledTodo[]
// Account linking (see lib/actions/account-links.ts): pending link
// requests this account has sent or received, confirmed links it is part
// of, request blocks it has issued, and switch tokens it has requested.
linkRequestsFrom AccountLinkRequest[] @relation("LinkRequestFrom")
linkRequestsTo AccountLinkRequest[] @relation("LinkRequestTo")
accountLinksUser AccountLink[] @relation("AccountLinkUser")
accountLinksLinked AccountLink[] @relation("AccountLinkLinkedUser")
linkBlocksFrom AccountLinkBlock[] @relation("LinkBlockFrom")
linkBlocksTo AccountLinkBlock[] @relation("LinkBlockTo")
switchTokenRequests AccountSwitchToken[] @relation("SwitchTokenRequester")
switchTokenTargets AccountSwitchToken[] @relation("SwitchTokenTarget")
}
/**
* A pending account-link request: `fromUser` asked to link with `toUser`,
* who must decide ("Create Account Link", "Deny Account Link", or "Deny
* and Block Account Link" on the Profile page). At most one pending
* request per direction per pair (the composite unique), so re-requesting
* is a no-op upsert rather than a duplicate. Denied requests are deleted,
* not archived -- a later request simply starts fresh.
*/
model AccountLinkRequest {
id String @id @default(cuid())
fromUserId String
toUserId String
createdAt DateTime @default(now())
fromUser User @relation("LinkRequestFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
toUser User @relation("LinkRequestTo", fields: [toUserId], references: [id], onDelete: Cascade)
@@unique([fromUserId, toUserId])
@@index([toUserId])
}
/**
* A confirmed account link, stored exactly once per pair (either order) --
* lib/actions/account-links.ts normalizes the pair into the same order
* before creating, and the composite unique below makes a concurrent
* double-confirm fail with a unique violation instead of a duplicate row.
* Either linked account can remove the link; removing it does not prevent
* re-requesting (the request flow starts over).
* `userId` and `linkedUserId` must differ -- enforced by the
* account_link_not_self check constraint in the migration (Prisma has no
* schema-level CHECK syntax).
*/
model AccountLink {
id String @id @default(cuid())
userId String
linkedUserId String
createdAt DateTime @default(now())
user User @relation("AccountLinkUser", fields: [userId], references: [id], onDelete: Cascade)
linkedUser User @relation("AccountLinkLinkedUser", fields: [linkedUserId], references: [id], onDelete: Cascade)
@@unique([userId, linkedUserId])
}
/**
* A denied-and-blocked pair: `fromUser` may no longer send account-link
* requests to `toUser` (checked in requestAccountLink). Created by the
* "Deny and Block Account Link" action; reversible by the blocked-against
* account (unblockAccountLinkRequests), since a block is permanent for the
* requester otherwise.
*/
model AccountLinkBlock {
id String @id @default(cuid())
fromUserId String
toUserId String
createdAt DateTime @default(now())
fromUser User @relation("LinkBlockFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
toUser User @relation("LinkBlockTo", fields: [toUserId], references: [id], onDelete: Cascade)
@@unique([fromUserId, toUserId])
}
/**
* A short-lived, single-use token letting one linked account switch the
* browser's session to the other without knowing its password (the
* Profile page "Toggle" button). Created by toggleAccount (lib/actions/
* account-links.ts) after the link is verified, consumed exactly once by
* authorize() in the "account-switch" provider of auth.ts.
*/
model AccountSwitchToken {
id String @id @default(cuid())
token String @unique
requestedBy String
targetUserId String
// Absolute expiry -- the token is dead after this even if unused.
expiresAt DateTime
// Set the moment authorize() accepts it. A token with usedAt set is
// never valid again, so a replayed URL can't switch twice.
usedAt DateTime?
createdAt DateTime @default(now())
requester User @relation("SwitchTokenRequester", fields: [requestedBy], references: [id], onDelete: Cascade)
target User @relation("SwitchTokenTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
}
/**
@ -61,6 +177,11 @@ model Project {
id String @id @default(cuid())
title String
ownerId String
// The theme assigned to this project (one of lib/themes' THEMES). Null =
// "Default Theme": follow whatever the user picked in the global theme
// menu. Applied only while the project's own page is open (see
// components/theme/project-theme-scope.tsx).
theme String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -17,6 +17,11 @@ async function main() {
email: "dev@example.com",
passwordHash,
name: "Dev User",
// Mirrors the migration's backfill of the legacy `name` into the
// profile fields (first word / rest) so seeded accounts look the
// same as ones that predate the Profile page.
firstName: "Dev",
lastName: "User",
role: Role.ADMIN,
},
});

BIN
shots/m01-home-board.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
shots/m02-home-drawer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
shots/m03-home-lane2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
shots/m05-projects.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
shots/m06-chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
shots/m07-tablet-768.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
shots/m08-desktop-1440.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

34
types/profile.ts Normal file
View File

@ -0,0 +1,34 @@
/** The signed-in user's profile as rendered by the Profile page and the
* sidebar -- nulls mean "not set". */
export interface ProfileDTO {
firstName: string | null;
lastName: string | null;
avatar: string | null;
}
/** A shared identity display: name when the account set one (profile or
* sign-up name), otherwise null and the email stands on its own. */
export interface LinkAccountIdentity {
id: string;
email: string;
name: string | null;
avatar: string | null;
}
/** A pending link request addressed to the signed-in account (they asked,
* we decide). */
export interface PendingLinkRequestDTO extends LinkAccountIdentity {
requestId: string;
requestedAtLabel: string;
}
/** A confirmed link: the other account in the pair. */
export interface LinkedAccountDTO extends LinkAccountIdentity {
linkId: string;
}
/** An account the signed-in account blocked from sending link requests. */
export interface BlockedRequesterDTO extends LinkAccountIdentity {
blockId: string;
blockedAtLabel: string;
}

View File

@ -1,4 +1,10 @@
import type { ThemeName } from "@/lib/themes";
export interface ProjectDTO {
id: string;
title: string;
// The theme assigned to this project (see components/theme/). Null =
// "Default Theme": follow whatever the user picked in the global theme
// menu, like every other page.
theme: ThemeName | null;
}