Compare commits

..

No commits in common. "main" and "Theme-Stuff" have entirely different histories.

65 changed files with 254 additions and 3566 deletions

View File

@ -1,136 +0,0 @@
/* 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

@ -1,109 +0,0 @@
/* 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); });

View File

@ -1,153 +0,0 @@
/* 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,10 +19,6 @@ given instance automatically becomes its administrator.
- Per-Group markdown notes and a to-do list with a completion progress - Per-Group markdown notes and a to-do list with a completion progress
indicator indicator
- Light/dark theme (follows system by default) - 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 - Installable PWA (add-to-home-screen); requires a live connection to the
server for its database, so there's no offline mode server for its database, so there's no offline mode
- Credentials-based accounts (email + password), with an Admin page to: - Credentials-based accounts (email + password), with an Admin page to:
@ -30,24 +26,6 @@ given instance automatically becomes its administrator.
password, or delete their account password, or delete their account
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes) - control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
below) 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 ## Tech stack

View File

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

View File

@ -1,134 +0,0 @@
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,9 +5,7 @@ import { prisma } from "@/lib/db";
import { projectAccessFilter } from "@/lib/access"; import { projectAccessFilter } from "@/lib/access";
import { getBoard } from "@/lib/board"; import { getBoard } from "@/lib/board";
import { getAiSettingsView } from "@/lib/ai-settings"; import { getAiSettingsView } from "@/lib/ai-settings";
import { themeSwitchScript, type ThemeName } from "@/lib/themes";
import { KanbanBoard } from "@/components/board/kanban-board"; import { KanbanBoard } from "@/components/board/kanban-board";
import { ProjectThemeScope } from "@/components/theme/project-theme-scope";
export default async function ProjectPage({ export default async function ProjectPage({
params, params,
@ -20,7 +18,7 @@ export default async function ProjectPage({
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(session.user.id) }, where: { id: projectId, ...projectAccessFilter(session.user.id) },
select: { id: true, title: true, theme: true }, select: { id: true, title: true },
}); });
// Same response whether the project doesn't exist or just isn't this // Same response whether the project doesn't exist or just isn't this
// user's -- no need to distinguish "not found" from "not yours". // user's -- no need to distinguish "not found" from "not yours".
@ -32,28 +30,12 @@ export default async function ProjectPage({
]); ]);
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model); 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 ( return (
<>
{scopedTheme && (
<script dangerouslySetInnerHTML={{ __html: themeSwitchScript(scopedTheme) }} />
)}
<ProjectThemeScope projectId={project.id} />
<KanbanBoard <KanbanBoard
initialCategories={board} initialCategories={board}
projectId={project.id} projectId={project.id}
title={project.title} title={project.title}
aiConfigured={aiConfigured} aiConfigured={aiConfigured}
/> />
</>
); );
} }

View File

@ -980,34 +980,6 @@ html.theme-blueprint {
html.theme-blueprint [data-slot="button"] { html.theme-blueprint [data-slot="button"] {
position: relative; 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"]::before,
html.theme-blueprint [data-slot="button"]::after { html.theme-blueprint [data-slot="button"]::after {
content: ""; content: "";
@ -1748,34 +1720,17 @@ html.theme-vaporwave .vw-palm--right-small {
that inline value (animations sit above inline styles in the cascade) -- that inline value (animations sit above inline styles in the cascade) --
the card stops following the pointer. opacity-50 is exactly the class the 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. card gets while dragging, so the hiccup stands down for the duration.
(The tracking band sweeps inside a .vw-glitch-clip clip box rendered by (overflow: hidden keeps the tracking band clipped to the card while it
GroupCard rather than relying on overflow: hidden on the card itself -- sweeps; the hover lift uses the separate `translate` property, so the
the card must stay overflow-visible so the progress pie, which pokes jitter doesn't fight it.) */
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) { html.theme-vaporwave [data-group-card]:not(.opacity-50) {
overflow: hidden;
animation: vw-card-glitch 10s linear infinite; animation: vw-card-glitch 10s linear infinite;
} }
html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 { html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 {
animation: vw-title-glitch 10s linear infinite; animation: vw-title-glitch 10s linear infinite;
} }
/* The band's clip box: a full-card overlay (in GroupCard) that carries the html.theme-vaporwave [data-group-card]:not(.opacity-50)::after {
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: ""; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
@ -1801,14 +1756,14 @@ html.theme-vaporwave [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
first delay elapses, and under reduced motion, everything just sits still.) */ 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),
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) > div:first-child h3,
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after { html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50)::after {
animation-duration: 13s; animation-duration: 13s;
animation-delay: 3s; animation-delay: 3s;
} }
/* Phase C: the longest period, offset furthest. */ /* 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),
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) > div:first-child h3,
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after { html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50)::after {
animation-duration: 17s; animation-duration: 17s;
animation-delay: 7s; animation-delay: 7s;
} }

View File

@ -15,9 +15,6 @@ import { ThemeProvider } from "@/components/theme/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { RegisterServiceWorker } from "@/components/pwa/register-sw"; 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 /** UI type: Inter -- a neutral, highly legible humanist sans that reads
* cleanly at small sizes (to-do lists, tables, nav). */ * cleanly at small sizes (to-do lists, tables, nav). */
@ -97,28 +94,7 @@ export const viewport: Viewport = {
], ],
}; };
export default async function RootLayout({ children }: LayoutProps<"/">) { export default 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 ( return (
<html <html
lang="en" lang="en"
@ -134,13 +110,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
their own fonts, backgrounds, and animations. their own fonts, backgrounds, and animations.
"system" resolves to Default/Dark by OS preference. "system" resolves to Default/Dark by OS preference.
No-FOUC theme restore: runs before first paint, applies the No-FOUC theme restore: runs before first paint, applies the
right class + color-scheme to <html>. Signed-in users get stored preference to <html> (class + color-scheme). */}
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 <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: themeInitScript(userTheme), __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){}})();`,
}} }}
/> />
{/* Vaporwave scenery (see app/globals.css): the outrun floor grid, {/* Vaporwave scenery (see app/globals.css): the outrun floor grid,
@ -173,7 +146,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
<svg className="vw-palm vw-palm--right-small" aria-hidden> <svg className="vw-palm vw-palm--right-small" aria-hidden>
<use href="#vw-palm" /> <use href="#vw-palm" />
</svg> </svg>
<ThemeProvider userTheme={userTheme}> <ThemeProvider defaultTheme="system">
<TooltipProvider delay={200}> <TooltipProvider delay={200}>
{children} {children}
<Toaster /> <Toaster />

47
auth.ts
View File

@ -5,7 +5,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { LoginSchema } from "@/lib/validation/auth"; import { LoginSchema } from "@/lib/validation/auth";
import { Role } from "@/lib/generated/prisma/enums"; import { Role } from "@/lib/generated/prisma/enums";
import { PendingAccountSignin, SwitchAccountSignin } from "@/lib/auth-errors"; import { PendingAccountSignin } from "@/lib/auth-errors";
// Credentials-only, JWT sessions, no database adapter: with a single // Credentials-only, JWT sessions, no database adapter: with a single
// Credentials provider and no OAuth, there's nothing for a DB-backed // Credentials provider and no OAuth, there's nothing for a DB-backed
@ -20,11 +20,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
signIn: "/login", signIn: "/login",
}, },
providers: [ 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({ Credentials({
id: "credentials",
credentials: { credentials: {
email: {}, email: {},
password: {}, password: {},
@ -44,47 +40,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// email confirmation) and can't log in yet -- see lib/settings.ts. // email confirmation) and can't log in yet -- see lib/settings.ts.
if (user.role === Role.PENDING) throw new PendingAccountSignin(); 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 }; return { id: user.id, email: user.email, name: user.name, role: user.role };
}, },
}), }),

View File

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

View File

@ -39,10 +39,7 @@ export function AddCategoryLane() {
render={ render={
<button <button
data-board-chrome="" data-board-chrome=""
// Mobile: a full-width snap page below the lanes (stacked under 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"
// 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" aria-label="Add category"
> >
<span className="flex size-9 items-center justify-center rounded-full bg-foreground/5"> <span className="flex size-9 items-center justify-center rounded-full bg-foreground/5">

View File

@ -10,10 +10,16 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker"; import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider"; import { useBoardView } from "@/components/board/board-view-provider";
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete"; import { useHoldOnComplete } from "@/components/hold-on-complete";
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors"; import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
const TITLE_MAX = 20; 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 }) { export function AddGroupPopover({ categoryId }: { categoryId: string }) {
const { addGroup } = useBoard(); const { addGroup } = useBoard();

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react"; import { createContext, useContext, useState, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board"; import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
@ -25,13 +25,6 @@ import {
deleteTodo as deleteTodoAction, deleteTodo as deleteTodoAction,
} from "@/lib/actions/todos"; } from "@/lib/actions/todos";
import { updateGroupNote } from "@/lib/actions/notes"; 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 { interface BoardContextValue {
categories: CategoryDTO[]; categories: CategoryDTO[];
@ -82,10 +75,6 @@ interface BoardContextValue {
completed: boolean completed: boolean
) => Promise<void>; ) => Promise<void>;
removeTodo: (todoId: string, groupId: string, categoryId: string) => 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); const BoardContext = createContext<BoardContextValue | null>(null);
@ -107,94 +96,6 @@ export function BoardProvider({
}) { }) {
const [categories, setCategories] = useState(initialCategories); 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( const updateCategory = useCallback(
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => { (categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c))); setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
@ -215,13 +116,13 @@ export function BoardProvider({
const addCategory = useCallback( const addCategory = useCallback(
async (name: string) => { async (name: string) => {
try { try {
const category = await track(createCategory(name, projectId)); const category = await createCategory(name, projectId);
setCategories((prev) => [...prev, category]); setCategories((prev) => [...prev, category]);
} catch { } catch {
toast.error("Couldn't create category. Try again."); toast.error("Couldn't create category. Try again.");
} }
}, },
[projectId, track] [projectId]
); );
const removeCategory = useCallback(async (categoryId: string) => { const removeCategory = useCallback(async (categoryId: string) => {
@ -231,7 +132,7 @@ export function BoardProvider({
return prev.filter((c) => c.id !== categoryId); return prev.filter((c) => c.id !== categoryId);
}); });
try { try {
const result = await track(deleteCategoryAction(categoryId)); const result = await deleteCategoryAction(categoryId);
if (result?.error) { if (result?.error) {
setCategories(prevState); setCategories(prevState);
toast.error(result.error); toast.error(result.error);
@ -240,7 +141,7 @@ export function BoardProvider({
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't delete category. Try again."); toast.error("Couldn't delete category. Try again.");
} }
}, [track]); }, []);
const reorderLanes = useCallback( const reorderLanes = useCallback(
async (orderedIds: string[]) => { async (orderedIds: string[]) => {
@ -251,36 +152,36 @@ export function BoardProvider({
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order })); return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
}); });
try { try {
await track(reorderCategories(orderedIds, projectId)); await reorderCategories(orderedIds, projectId);
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't reorder categories. Try again."); toast.error("Couldn't reorder categories. Try again.");
} }
}, },
[projectId, track] [projectId]
); );
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => { const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
try { try {
const group = await track(createGroup(categoryId, title, color)); const group = await createGroup(categoryId, title, color);
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] })); updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
return group; return group;
} catch { } catch {
toast.error("Couldn't create group. Try again."); toast.error("Couldn't create group. Try again.");
return undefined; return undefined;
} }
}, [updateCategory, track]); }, [updateCategory]);
const editGroup = useCallback( const editGroup = useCallback(
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => { async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
try { try {
await track(updateGroupAction(groupId, data)); await updateGroupAction(groupId, data);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data })); updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
} catch { } catch {
toast.error("Couldn't update group. Try again."); toast.error("Couldn't update group. Try again.");
} }
}, },
[updateGroupInState, track] [updateGroupInState]
); );
const removeGroup = useCallback(async (groupId: string, categoryId: string) => { const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
@ -292,12 +193,12 @@ export function BoardProvider({
); );
}); });
try { try {
await track(deleteGroupAction(groupId)); await deleteGroupAction(groupId);
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't delete group. Try again."); toast.error("Couldn't delete group. Try again.");
} }
}, [track]); }, []);
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => { const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
let prevState: CategoryDTO[] = []; let prevState: CategoryDTO[] = [];
@ -308,12 +209,12 @@ export function BoardProvider({
); );
}); });
try { try {
await track(archiveGroupAction(groupId)); await archiveGroupAction(groupId);
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't archive group. Try again."); toast.error("Couldn't archive group. Try again.");
} }
}, [track]); }, []);
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => { const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
let prevState: CategoryDTO[] = []; let prevState: CategoryDTO[] = [];
@ -326,12 +227,12 @@ export function BoardProvider({
}); });
}); });
try { try {
await track(reorderGroupsInCategory(categoryId, orderedIds)); await reorderGroupsInCategory(categoryId, orderedIds);
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't reorder groups. Try again."); toast.error("Couldn't reorder groups. Try again.");
} }
}, [track]); }, []);
const moveGroup = useCallback( const moveGroup = useCallback(
async ( async (
@ -372,19 +273,19 @@ export function BoardProvider({
}); });
}); });
try { try {
await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource)); await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource);
} catch { } catch {
setCategories(prevState); setCategories(prevState);
toast.error("Couldn't move group. Try again."); toast.error("Couldn't move group. Try again.");
} }
}, },
[track] []
); );
const saveNote = useCallback( const saveNote = useCallback(
async (groupId: string, categoryId: string, noteContent: string) => { async (groupId: string, categoryId: string, noteContent: string) => {
try { try {
await track(updateGroupNote(groupId, noteContent)); await updateGroupNote(groupId, noteContent);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent })); updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
return true; return true;
} catch { } catch {
@ -392,13 +293,13 @@ export function BoardProvider({
return false; return false;
} }
}, },
[updateGroupInState, track] [updateGroupInState]
); );
const addTodo = useCallback( const addTodo = useCallback(
async (groupId: string, categoryId: string, title: string, details?: string) => { async (groupId: string, categoryId: string, title: string, details?: string) => {
try { try {
const todo = await track(createTodo(groupId, title, details)); const todo = await createTodo(groupId, title, details);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] })); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
return true; return true;
} catch { } catch {
@ -406,13 +307,13 @@ export function BoardProvider({
return false; return false;
} }
}, },
[updateGroupInState, track] [updateGroupInState]
); );
const addTodos = useCallback( const addTodos = useCallback(
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => { async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
try { try {
const created = await track(createTodos(groupId, todos)); const created = await createTodos(groupId, todos);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] })); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
return true; return true;
} catch { } catch {
@ -420,7 +321,7 @@ export function BoardProvider({
return false; return false;
} }
}, },
[updateGroupInState, track] [updateGroupInState]
); );
const editTodo = useCallback( const editTodo = useCallback(
@ -431,7 +332,7 @@ export function BoardProvider({
data: { title?: string; details?: string | null } data: { title?: string; details?: string | null }
) => { ) => {
try { try {
await track(updateTodoAction(todoId, data)); await updateTodoAction(todoId, data);
// Approximates the server's own `updatedAt` (set by the same write, // Approximates the server's own `updatedAt` (set by the same write,
// a moment later) closely enough for display purposes, without // a moment later) closely enough for display purposes, without
// waiting on a round trip just to read it back. // waiting on a round trip just to read it back.
@ -446,7 +347,7 @@ export function BoardProvider({
return false; return false;
} }
}, },
[updateGroupInState, track] [updateGroupInState]
); );
const toggleTodoDone = useCallback( const toggleTodoDone = useCallback(
@ -469,13 +370,13 @@ export function BoardProvider({
}), }),
})); }));
try { try {
await track(toggleTodoAction(todoId, completed)); await toggleTodoAction(todoId, completed);
} catch { } catch {
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos })); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
toast.error("Couldn't update to-do. Try again."); toast.error("Couldn't update to-do. Try again.");
} }
}, },
[updateCategory, updateGroupInState, track] [updateCategory, updateGroupInState]
); );
const removeTodo = useCallback( const removeTodo = useCallback(
@ -490,13 +391,13 @@ export function BoardProvider({
}), }),
})); }));
try { try {
await track(deleteTodoAction(todoId)); await deleteTodoAction(todoId);
} catch { } catch {
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos })); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
toast.error("Couldn't delete to-do. Try again."); toast.error("Couldn't delete to-do. Try again.");
} }
}, },
[updateCategory, updateGroupInState, track] [updateCategory, updateGroupInState]
); );
return ( return (
@ -519,7 +420,6 @@ export function BoardProvider({
editTodo, editTodo,
toggleTodoDone, toggleTodoDone,
removeTodo, removeTodo,
suspendRemoteSync,
}} }}
> >
{children} {children}

View File

@ -1,81 +0,0 @@
"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 { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core"; import { useDndContext, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { GripVertical, MoreVertical, Trash2 } from "lucide-react"; import { GripVertical, MoreVertical, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -98,13 +98,9 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
<div <div
ref={setNodeRef} ref={setNodeRef}
data-category-lane="" data-category-lane=""
data-category-id={category.id}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn( className={cn(
// w-full on mobile: the lane pager gives each lane the whole "flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane",
// 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" isDragging && "opacity-50"
)} )}
> >
@ -166,16 +162,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
// leaves enough clearance that a card's hover lift // leaves enough clearance that a card's hover lift
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke // (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
// under the lane header above it. // 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" isDropTargetLane && "bg-primary/10"
)} )}
> >

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { import {
@ -32,7 +32,7 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme"; import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider"; import { useBoardView } from "@/components/board/board-view-provider";
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete"; import { useHoldOnComplete } from "@/components/hold-on-complete";
import { NotesDialog } from "@/components/board/notes-dialog"; import { NotesDialog } from "@/components/board/notes-dialog";
import { TodoAiDialog } from "@/components/board/todo-ai-dialog"; import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
@ -89,7 +89,7 @@ function AddTodoMenu({
export function GroupCard({ group }: { group: GroupDTO }) { export function GroupCard({ group }: { group: GroupDTO }) {
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard(); const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
const { view } = useBoardView(); const { view } = useBoardView();
const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete(); const { holds, beginHold, cancelHold } = useHoldOnComplete();
const compact = view === "compact"; const compact = view === "compact";
// True for any theme whose surfaces are dark -- Dark and Ocean both use // 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`. // their `dark` color variant (see lib/colors.ts); light themes use `light`.
@ -101,39 +101,6 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const [todoCreateOpen, setTodoCreateOpen] = useState(false); const [todoCreateOpen, setTodoCreateOpen] = useState(false);
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false); const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
const [expandedWhileComplete, setExpandedWhileComplete] = 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 { const {
attributes, attributes,
@ -374,8 +341,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<AddTodoMenu <AddTodoMenu
group={group} group={group}
aiConfigured={aiConfigured} aiConfigured={aiConfigured}
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)} onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)} onAiClick={() => setTodoAiOpen(true)}
/> />
)} )}
</div> </div>
@ -395,8 +362,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<div className="mt-1 flex justify-end"> <div className="mt-1 flex justify-end">
<AddTodoMenu <AddTodoMenu
group={group} group={group}
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)} onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)} onAiClick={() => setTodoAiOpen(true)}
aiConfigured={aiConfigured} aiConfigured={aiConfigured}
/> />
</div> </div>
@ -439,11 +406,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent> <TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{/* pr-2.5: the corner progress pie (TodoProgressPie) hugs <span className="ml-auto text-[11px] font-medium tabular-nums text-muted-foreground">
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 {completedCount}/{group.todos.length} done
</span> </span>
</div> </div>
@ -458,14 +421,6 @@ 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> </div>
{editingTodo && ( {editingTodo && (
@ -480,17 +435,13 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} /> <EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<TodoAiDialog <TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
group={group}
open={todoAiOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
/>
<TodoCreateDialog <TodoCreateDialog
groupId={group.id} groupId={group.id}
categoryId={group.categoryId} categoryId={group.categoryId}
open={todoCreateOpen} open={todoCreateOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)} onOpenChange={setTodoCreateOpen}
/> />
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} /> <StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { import {
DndContext, DndContext,
DragOverlay, DragOverlay,
@ -17,14 +17,12 @@ import { LayoutDashboard, Plus } from "lucide-react";
import { BoardProvider, useBoard } from "@/components/board/board-context"; import { BoardProvider, useBoard } from "@/components/board/board-context";
import { CategoryLane } from "@/components/board/category-lane"; import { CategoryLane } from "@/components/board/category-lane";
import { CategoryChips } from "@/components/board/category-chips";
import { AddCategoryLane } from "@/components/board/add-category-lane"; import { AddCategoryLane } from "@/components/board/add-category-lane";
import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state"; import { EmptyState } from "@/components/board/empty-state";
import { ViewSwitcher } from "@/components/board/view-switcher"; import { ViewSwitcher } from "@/components/board/view-switcher";
import { SummaryButton } from "@/components/board/summary-button"; import { SummaryButton } from "@/components/board/summary-button";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
import { ProjectThemePicker } from "@/components/projects/project-theme-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { CategoryDTO, GroupDTO } from "@/types/board"; import type { CategoryDTO, GroupDTO } from "@/types/board";
@ -38,14 +36,9 @@ function Board({
projectId?: string; projectId?: string;
aiConfigured: boolean; aiConfigured: boolean;
}) { }) {
const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = useBoard(); const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null); const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
const [quickAddOpen, setQuickAddOpen] = useState(false); 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 // 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 // goal is one click from a keyboard or pointer to start typing a to-do
@ -67,11 +60,6 @@ function Board({
} }
function handleDragStart(event: DragStartEvent) { 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; const data = event.active.data.current;
if (data?.type === "group") { if (data?.type === "group") {
const category = findCategory(data.categoryId as string); const category = findCategory(data.categoryId as string);
@ -81,7 +69,6 @@ function Board({
} }
function handleDragEnd(event: DragEndEvent) { function handleDragEnd(event: DragEndEvent) {
suspendRemoteSync(false);
const { active, over } = event; const { active, over } = event;
setDraggedGroup(null); setDraggedGroup(null);
if (!over) return; if (!over) return;
@ -141,51 +128,6 @@ function Board({
const categoryIds = categories.map((c) => c.id); 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 ( return (
<DndContext <DndContext
id="board-dnd" id="board-dnd"
@ -194,22 +136,17 @@ function Board({
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex h-full flex-col gap-3 p-3 md:gap-4 md:p-4"> <div className="flex h-full flex-col gap-4 p-4">
<div <div
data-board-chrome="" data-board-chrome=""
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1" 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"> <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 md:flex"> <span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
<LayoutDashboard className="size-5" /> <LayoutDashboard className="size-5" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
<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"> <p className="truncate text-[13px] text-muted-foreground">
{categories.length === 0 {categories.length === 0
? "Add a lane to get started" ? "Add a lane to get started"
@ -236,10 +173,9 @@ function Board({
className="gap-1.5" className="gap-1.5"
disabled={!quickTargetGroup} disabled={!quickTargetGroup}
onClick={() => setQuickAddOpen(true)} onClick={() => setQuickAddOpen(true)}
aria-label="Add to-do"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
<span className="hidden md:inline">To-do</span> To-do
</Button> </Button>
} }
/> />
@ -252,29 +188,12 @@ function Board({
</div> </div>
{categories.length === 0 ? ( {categories.length === 0 ? (
<div className="flex flex-1 flex-col items-stretch gap-4 overflow-y-auto md:flex-row md:items-center md:overflow-x-auto"> <div className="flex flex-1 items-center gap-4 overflow-x-auto">
<EmptyState /> <EmptyState />
<AddCategoryLane /> <AddCategoryLane />
</div> </div>
) : ( ) : (
<> <div className="flex flex-1 items-start gap-4 overflow-x-auto pb-2">
{/* 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}> <SortableContext items={categoryIds} strategy={horizontalListSortingStrategy}>
{categories.map((category) => ( {categories.map((category) => (
<CategoryLane key={category.id} category={category} /> <CategoryLane key={category.id} category={category} />
@ -282,7 +201,6 @@ function Board({
</SortableContext> </SortableContext>
<AddCategoryLane /> <AddCategoryLane />
</div> </div>
</>
)} )}
</div> </div>

View File

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

View File

@ -40,20 +40,7 @@ export function TodoProgressPie({
// context (see hover:-translate-y-0.5 in GroupCard), so // context (see hover:-translate-y-0.5 in GroupCard), so
// without an explicit z-index here it paints on top and crops // without an explicit z-index here it paints on top and crops
// the badge's overlapping edge. // the badge's overlapping edge.
// -right-2.5: the badge's horizontal position is a budget, not 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"
// 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={{ style={{
borderColor: accentColor, borderColor: accentColor,
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`, background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,

View File

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

View File

@ -26,20 +26,9 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a // Ids are opaque strings, so unrelated features (a to-do id, a group id, a
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can // scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
// safely share one instance without knowing about each other. // 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 HOLD_MS = 6_000;
const FADE_MS = 1_000; const FADE_MS = 1_000;
const COLLAPSE_MS = 200; 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"; export type HoldPhase = "visible" | "fading" | "collapsing";
@ -50,12 +39,6 @@ interface HoldOnCompleteContextValue {
// override just the "visible" stage's length. // override just the "visible" stage's length.
beginHold: (id: string, holdMs?: number) => void; beginHold: (id: string, holdMs?: number) => void;
cancelHold: (id: string) => 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); const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
@ -103,25 +86,6 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
[clearTimers, setPhase] [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 // Belt-and-suspenders: drop any still-pending timers on unmount so they
// don't fire setState against a gone provider. // don't fire setState against a gone provider.
useEffect(() => { useEffect(() => {
@ -132,7 +96,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
}, []); }, []);
return ( return (
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}> <HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
{children} {children}
</HoldOnCompleteContext.Provider> </HoldOnCompleteContext.Provider>
); );

View File

@ -1,36 +0,0 @@
"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,10 +7,6 @@ const STORAGE_KEY = "organize:sidenav-collapsed";
interface SideNavContextValue { interface SideNavContextValue {
collapsed: boolean; collapsed: boolean;
toggle: () => void; 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); const SideNavContext = createContext<SideNavContextValue | null>(null);
@ -20,7 +16,6 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) {
// hydration mismatch; the real persisted value is applied right after // hydration mismatch; the real persisted value is applied right after
// mount, trading a one-frame flash for zero hydration warnings. // mount, trading a one-frame flash for zero hydration warnings.
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@ -36,7 +31,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) {
}; };
return ( return (
<SideNavContext.Provider value={{ collapsed, toggle, mobileOpen, setMobileOpen }}> <SideNavContext.Provider value={{ collapsed, toggle }}>
{children} {children}
</SideNavContext.Provider> </SideNavContext.Provider>
); );

View File

@ -1,8 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react"; import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,72 +13,17 @@ import { ThemeToggle } from "@/components/theme/theme-toggle";
import { logout } from "@/lib/actions/auth"; import { logout } from "@/lib/actions/auth";
import { Role } from "@/lib/generated/prisma/enums"; import { Role } from "@/lib/generated/prisma/enums";
/** export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
* The signed-in user's circle at the bottom of the nav: their photo when const { collapsed, toggle } = useSideNav();
* 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] : []; 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 ( 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")}> <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"> <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" /> <NotebookPen className="size-4.5" />
@ -103,7 +46,7 @@ function SideNavContent({
</nav> </nav>
<div className="space-y-0.5 px-2.5 pb-2"> <div className="space-y-0.5 px-2.5 pb-2">
<ThemeToggle collapsed={collapsed} menuSide={themeMenu.side} menuAlign={themeMenu.align} /> <ThemeToggle collapsed={collapsed} />
{collapsed ? ( {collapsed ? (
<form action={logout}> <form action={logout}>
@ -129,123 +72,13 @@ function SideNavContent({
</div> </div>
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}> <div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
{collapsed ? ( <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
<Tooltip> {(userEmail[0] ?? "?").slice(0, 1)}
<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> </span>
</Link> {!collapsed && (
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
)} )}
</div> </div>
</>
);
}
/**
* 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")}> <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"> <Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
@ -253,8 +86,5 @@ export function SideNav({
</Button> </Button>
</div> </div>
</aside> </aside>
<MobileNavDrawer userEmail={userEmail} userName={userName} avatar={avatar} role={role} />
</>
); );
} }

View File

@ -1,33 +0,0 @@
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

@ -1,90 +0,0 @@
"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

@ -1,132 +0,0 @@
"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

@ -1,157 +0,0 @@
"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

@ -1,211 +0,0 @@
"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

@ -1,103 +0,0 @@
"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

@ -1,145 +0,0 @@
"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,13 +3,11 @@
import { createContext, useContext, useState, useCallback } from "react"; import { createContext, useContext, useState, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ThemeName } from "@/lib/themes";
import type { ProjectDTO } from "@/types/project"; import type { ProjectDTO } from "@/types/project";
import { import {
createProject, createProject,
renameProject as renameProjectAction, renameProject as renameProjectAction,
deleteProject as deleteProjectAction, deleteProject as deleteProjectAction,
setProjectTheme as setProjectThemeAction,
} from "@/lib/actions/projects"; } from "@/lib/actions/projects";
interface ProjectsContextValue { interface ProjectsContextValue {
@ -17,10 +15,6 @@ interface ProjectsContextValue {
addProject: (title: string) => Promise<ProjectDTO | undefined>; addProject: (title: string) => Promise<ProjectDTO | undefined>;
renameProject: (projectId: string, title: string) => Promise<void>; renameProject: (projectId: string, title: string) => Promise<void>;
removeProject: (projectId: string) => Promise<boolean>; 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); const ProjectsContext = createContext<ProjectsContextValue | null>(null);
@ -81,27 +75,8 @@ export function ProjectsProvider({
return true; 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 ( return (
<ProjectsContext.Provider <ProjectsContext.Provider value={{ projects, addProject, renameProject, removeProject }}>
value={{ projects, addProject, renameProject, removeProject, setProjectTheme }}
>
{children} {children}
</ProjectsContext.Provider> </ProjectsContext.Provider>
); );

View File

@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; 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 { cn } from "@/lib/utils";
import { formatWeekdayDate } from "@/lib/format"; import { formatWeekdayDate } from "@/lib/format";
@ -103,9 +102,6 @@ export function ScheduledPanel() {
const [board, setBoard] = useState<ScheduledBoardDTO | null>(null); const [board, setBoard] = useState<ScheduledBoardDTO | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); 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 { holds, beginHold, cancelHold } = useHoldOnComplete();
const refresh = useCallback(() => { const refresh = useCallback(() => {
@ -207,7 +203,7 @@ export function ScheduledPanel() {
<> <>
<aside <aside
className={cn( className={cn(
"hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 md:flex", "hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 sm:flex",
collapsed ? "w-16" : "w-80" collapsed ? "w-16" : "w-80"
)} )}
> >
@ -232,77 +228,6 @@ export function ScheduledPanel() {
)} )}
</aside> </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 <ScheduledTodoDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
@ -489,7 +414,7 @@ function ExpandedPanel({
<Separator /> <Separator />
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{!board ? ( {!board ? (
<p className="p-2 text-center text-sm text-muted-foreground">Loading</p> <p className="p-2 text-center text-sm text-muted-foreground">Loading</p>
) : ( ) : (

View File

@ -1,37 +0,0 @@
"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,20 +11,6 @@ import {
} from "react"; } from "react";
import type { ReactNode } 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). * App theme provider (replaces next-themes).
* *
@ -37,33 +23,73 @@ export type { ThemeName, RawTheme };
* *
* Semantics (kept compatible for all existing consumers): * Semantics (kept compatible for all existing consumers):
* - `theme` the stored preference: one of THEMES, or "system" * - `theme` the stored preference: one of THEMES, or "system"
* - `resolvedTheme` the theme actually applied, always one of THEMES * - `resolvedTheme` the theme actually applied (system resolved via
* prefers-color-scheme), always one of THEMES
* - html classes .theme-default | .theme-sunset | ... (see globals.css) * - 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 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); 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 { interface ThemeContextValue {
theme: RawTheme; theme: RawTheme;
@ -72,21 +98,12 @@ interface ThemeContextValue {
themes: readonly ThemeName[]; themes: readonly ThemeName[];
} }
interface ThemeScopeValue {
scope: ThemeName | null;
setScope: (scope: ThemeName | null) => void;
}
const ThemeContext = createContext<ThemeContextValue>({ const ThemeContext = createContext<ThemeContextValue>({
theme: "system", theme: "system",
resolvedTheme: "default", resolvedTheme: "default",
setTheme: () => {}, setTheme: () => {},
themes: THEMES, themes: THEMES,
}); });
const ThemeScopeContext = createContext<ThemeScopeValue>({
scope: null,
setScope: () => {},
});
function systemPrefersDark(): boolean { function systemPrefersDark(): boolean {
return ( return (
@ -106,99 +123,54 @@ export function resolveTheme(theme: RawTheme): ThemeName {
return resolve(theme); return resolve(theme);
} }
function applyToDom(name: ThemeName) { function applyToDom(theme: RawTheme) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const name = resolve(theme);
const root = document.documentElement; const root = document.documentElement;
// Also drop "light": a class next-themes applied to <html> in earlier // Also drop "light": a class next-themes applied to <html> in earlier
// versions, harmless but stale. // versions, harmless but stale.
root.classList.remove(...ALL_CLASSES, "light"); root.classList.remove(...ALL_CLASSES, "light");
root.classList.add(THEME_CLASSES[name]); root.classList.add(THEME_CLASSES[name]);
root.style.colorScheme = themeColorScheme(name); root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
}
/** 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({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
userTheme,
}: { }: {
children: ReactNode; children: ReactNode;
defaultTheme?: RawTheme; 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 the initial theme // Both the server and the first client render agree on `defaultTheme`
// (hydration-safe); anonymous users' localStorage preference is picked up // (hydration-safe); the stored preference is picked up right after mount.
// right after mount. The inline script in app/layout.tsx already applied // The inline script in app/layout.tsx already applied the right classes
// the right classes before first paint, so there is no visible jump // before first paint, so there is no visible jump either way.
// either way. const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
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(() => { 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 { try {
const stored = localStorage.getItem(STORAGE_KEY); 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); if (stored) setThemeState(stored as RawTheme);
} catch { } catch {
/* storage unavailable -- stay on defaultTheme */ /* 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 resolved theme changes -- synchronously // Keep the DOM in sync whenever the preference changes -- synchronously
// (layout effect) so the CSS classes land in the same frame as the // (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. // JS-driven colors that derive from this state: no flash of stale colors.
useLayoutEffect(() => { useLayoutEffect(() => {
applyToDom(resolvedTheme); applyToDom(theme);
}, [resolvedTheme]); }, [theme]);
// Follow the OS while in "system" mode (and nothing is scoped). // Follow the OS while in "system" mode.
useEffect(() => { useEffect(() => {
if (scope || theme !== "system" || !window.matchMedia) return; if (theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom(resolve("system")); const onChange = () => applyToDom("system");
mq.addEventListener("change", onChange); mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange); return () => mq.removeEventListener("change", onChange);
}, [scope, theme]); }, [theme]);
// Keep multiple tabs in sync. // Keep multiple tabs in sync.
useEffect(() => { useEffect(() => {
@ -209,48 +181,23 @@ export function ThemeProvider({
return () => window.removeEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage);
}, [defaultTheme]); }, [defaultTheme]);
const setTheme = useCallback( const setTheme = useCallback((t: RawTheme) => {
(t: RawTheme) => {
setThemeState(t); setThemeState(t);
try { try {
localStorage.setItem(STORAGE_KEY, t); localStorage.setItem(STORAGE_KEY, t);
} catch { } catch {
/* ignore -- preference just won't persist */ /* 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]
);
const value = useMemo<ThemeContextValue>( const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }), () => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
[theme, resolvedTheme, setTheme], [theme, setTheme],
);
const scopeValue = useMemo<ThemeScopeValue>(
() => ({ scope, setScope }),
[scope],
); );
return ( return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
<ThemeScopeContext.Provider value={scopeValue}>
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
</ThemeScopeContext.Provider>
);
} }
export function useTheme(): ThemeContextValue { export function useTheme(): ThemeContextValue {
return useContext(ThemeContext); return useContext(ThemeContext);
} }
export function useThemeScope(): ThemeScopeValue {
return useContext(ThemeScopeContext);
}

View File

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

View File

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

View File

@ -53,12 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", "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",
// 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 className
)} )}
{...props} {...props}

View File

@ -9,11 +9,6 @@ services:
volumes: volumes:
# Local filesystem bind mount, not a named Docker volume. # Local filesystem bind mount, not a named Docker volume.
- ./data/postgres:/var/lib/postgresql/data - ./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: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s interval: 5s

View File

@ -1,299 +0,0 @@
"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;
}
}

View File

@ -1,27 +0,0 @@
"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 });
}

View File

@ -1,120 +0,0 @@
"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 { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers"; import { requireUserId } from "@/lib/auth-helpers";
import { projectAccessFilter } from "@/lib/access"; import { projectAccessFilter } from "@/lib/access";
import { ProjectTitleSchema, ProjectThemeSchema } from "@/lib/validation/project"; import { ProjectTitleSchema } from "@/lib/validation/project";
import type { ProjectDTO } from "@/types/project"; import type { ProjectDTO } from "@/types/project";
export async function createProject(title: string): Promise<ProjectDTO> { export async function createProject(title: string): Promise<ProjectDTO> {
@ -17,29 +17,7 @@ export async function createProject(title: string): Promise<ProjectDTO> {
}); });
revalidatePath("/projects"); revalidatePath("/projects");
return { id: project.id, title: project.title, theme: null }; return { id: project.id, title: project.title };
}
/**
* 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> { export async function renameProject(projectId: string, title: string): Promise<void> {

View File

@ -14,18 +14,3 @@ import { CredentialsSignin } from "next-auth";
export class PendingAccountSignin extends CredentialsSignin { export class PendingAccountSignin extends CredentialsSignin {
code = "pending-account"; 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";
}

View File

@ -1,133 +0,0 @@
/**
* 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){}`;
}

View File

@ -1,19 +0,0 @@
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,10 +1,3 @@
import { z } from "zod"; import { z } from "zod";
import { THEMES } from "@/lib/themes";
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60); 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,15 +7,6 @@ const nextConfig: NextConfig = {
// container start, not imported by app code). The Dockerfile instead // container start, not imported by app code). The Dockerfile instead
// copies the full `node_modules` into the runtime image, which is a // copies the full `node_modules` into the runtime image, which is a
// simpler and more reliable trade for a single self-hosted instance. // 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; export default nextConfig;

View File

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

View File

@ -1,16 +0,0 @@
-- 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

@ -1,85 +0,0 @@
-- 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

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

View File

@ -34,25 +34,7 @@ model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
passwordHash String 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? 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 // The very first person to sign up becomes ADMIN regardless of
// signupMode (the site needs at least one admin to bootstrap). Everyone // signupMode (the site needs at least one admin to bootstrap). Everyone
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED), // after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
@ -64,104 +46,6 @@ model User {
categories Category[] categories Category[]
projects Project[] @relation("ProjectOwner") projects Project[] @relation("ProjectOwner")
scheduledTodos ScheduledTodo[] 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)
} }
/** /**
@ -177,11 +61,6 @@ model Project {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
ownerId 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -17,11 +17,6 @@ async function main() {
email: "dev@example.com", email: "dev@example.com",
passwordHash, passwordHash,
name: "Dev User", 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, role: Role.ADMIN,
}, },
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View File

@ -1,34 +0,0 @@
/** 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,10 +1,4 @@
import type { ThemeName } from "@/lib/themes";
export interface ProjectDTO { export interface ProjectDTO {
id: string; id: string;
title: 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;
} }