Compare commits
No commits in common. "main" and "Small-UI-fixes" have entirely different histories.
main
...
Small-UI-f
|
|
@ -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); });
|
|
||||||
|
|
@ -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); });
|
|
||||||
|
|
@ -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); });
|
|
||||||
22
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1986
app/globals.css
122
app/layout.tsx
|
|
@ -1,23 +1,11 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import {
|
import { Figtree, Inter, JetBrains_Mono } from "next/font/google";
|
||||||
Architects_Daughter,
|
|
||||||
Caveat,
|
|
||||||
Figtree,
|
|
||||||
Inter,
|
|
||||||
JetBrains_Mono,
|
|
||||||
Orbitron,
|
|
||||||
Righteous,
|
|
||||||
VT323,
|
|
||||||
} from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
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). */
|
||||||
|
|
@ -39,42 +27,6 @@ const jetbrainsMono = JetBrains_Mono({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Blueprint theme's drafting face (see app/globals.css) -- a hand-drawn
|
|
||||||
* architectural face, lettered like a drawing's title block. */
|
|
||||||
const architectsDaughter = Architects_Daughter({
|
|
||||||
variable: "--font-blueprint",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: "400",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Cyberpunk theme's pixel-terminal face (see app/globals.css). */
|
|
||||||
const vt323 = VT323({
|
|
||||||
variable: "--font-cyberpunk",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: "400",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Vaporwave theme's retro face (see app/globals.css) -- a rounded,
|
|
||||||
* retro-futuristic face for the pastel synthwave look. */
|
|
||||||
const righteous = Righteous({
|
|
||||||
variable: "--font-vaporwave",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: "400",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Starfield theme's sci-fi face (see app/globals.css). */
|
|
||||||
const orbitron = Orbitron({
|
|
||||||
variable: "--font-starfield",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Notebook theme's handwriting face (see app/globals.css) -- a casual pen
|
|
||||||
* script for the stationery look. */
|
|
||||||
const caveat = Caveat({
|
|
||||||
variable: "--font-notebook",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Organize",
|
title: "Organize",
|
||||||
description: "An extended to-do list and notes organizer.",
|
description: "An extended to-do list and notes organizer.",
|
||||||
|
|
@ -97,83 +49,25 @@ 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"
|
||||||
className={`${inter.variable} ${figtree.variable} ${jetbrainsMono.variable} ${architectsDaughter.variable} ${vt323.variable} ${righteous.variable} ${orbitron.variable} ${caveat.variable} h-full antialiased`}
|
className={`${inter.variable} ${figtree.variable} ${jetbrainsMono.variable} h-full antialiased`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
{/* Nineteen looks: ten light (Default, Sunset, Meadow, Honey, Rosé,
|
{/* Four looks: Default (calm light), Sunset (warm light), Dark, Ocean
|
||||||
Lavender, Slate, Blueprint, Vaporwave, Notebook) and nine dark
|
(deep blue-green) -- complete token sets in app/globals.css.
|
||||||
(Dark, Ocean, Pine, Plum, Midnight, Ember, Rosewood, Cyberpunk,
|
|
||||||
Starfield) -- complete token sets in app/globals.css; Blueprint,
|
|
||||||
Cyberpunk, Vaporwave, Starfield, and Notebook additionally carry
|
|
||||||
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','dark','ocean','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',dark:'dark',ocean:'ocean'};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=(v==='dark'||v==='ocean')?'dark':'light';}}catch(e){}})();`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Vaporwave scenery (see app/globals.css): the outrun floor grid,
|
<ThemeProvider defaultTheme="system">
|
||||||
plus palm silhouettes at either side of the horizon. All fixed
|
|
||||||
behind the content, hidden by default; the theme class on <html>
|
|
||||||
switches them on. */}
|
|
||||||
<div className="vw-floor" aria-hidden />
|
|
||||||
<svg className="vw-palm-defs" aria-hidden>
|
|
||||||
<symbol id="vw-palm" viewBox="0 0 120 150">
|
|
||||||
<g fill="none" stroke="currentColor" strokeLinecap="round">
|
|
||||||
<path d="M60 150C57 110 56 80 60 45" strokeWidth="8" />
|
|
||||||
<path d="M60 45Q38 30 10 48" strokeWidth="9" />
|
|
||||||
<path d="M60 45Q44 18 20 12" strokeWidth="9" />
|
|
||||||
<path d="M60 45Q56 14 42 4" strokeWidth="9" />
|
|
||||||
<path d="M60 45Q66 12 84 6" strokeWidth="9" />
|
|
||||||
<path d="M60 45Q78 16 100 12" strokeWidth="9" />
|
|
||||||
<path d="M60 45Q84 30 110 50" strokeWidth="9" />
|
|
||||||
</g>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
<svg className="vw-palm vw-palm--left-main" aria-hidden>
|
|
||||||
<use href="#vw-palm" />
|
|
||||||
</svg>
|
|
||||||
<svg className="vw-palm vw-palm--left-small" aria-hidden>
|
|
||||||
<use href="#vw-palm" />
|
|
||||||
</svg>
|
|
||||||
<svg className="vw-palm vw-palm--right-main" aria-hidden>
|
|
||||||
<use href="#vw-palm" />
|
|
||||||
</svg>
|
|
||||||
<svg className="vw-palm vw-palm--right-small" aria-hidden>
|
|
||||||
<use href="#vw-palm" />
|
|
||||||
</svg>
|
|
||||||
<ThemeProvider userTheme={userTheme}>
|
|
||||||
<TooltipProvider delay={200}>
|
<TooltipProvider delay={200}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
|
||||||
47
auth.ts
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,7 @@ export function AddCategoryLane() {
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
data-board-chrome=""
|
className="flex h-full min-h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary"
|
||||||
// Mobile: a full-width snap page below the lanes (stacked under
|
|
||||||
// the EmptyState, full-width next to them); desktop: the usual
|
|
||||||
// dashed lane at the end of the row.
|
|
||||||
className="flex h-40 w-full shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary snap-start md:h-full md:min-h-32 md:w-72"
|
|
||||||
aria-label="Add category"
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -97,21 +97,13 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center gap-1.5 px-3 pt-3 pb-2">
|
||||||
data-board-chrome=""
|
|
||||||
className="flex items-center gap-1.5 px-3 pt-3 pb-2"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
ref={setActivatorNodeRef}
|
ref={setActivatorNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
|
|
@ -166,16 +158,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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { LayoutDashboard } from "lucide-react";
|
||||||
|
|
||||||
export function EmptyState() {
|
export function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 p-10 text-center">
|
||||||
data-board-chrome=""
|
|
||||||
className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 p-10 text-center"
|
|
||||||
>
|
|
||||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
<LayoutDashboard className="size-7" />
|
<LayoutDashboard className="size-7" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -28,11 +28,11 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
|
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||||
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
import { isDarkTheme } 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,
|
||||||
|
|
@ -154,11 +121,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
// card ever ringing itself while it's the one being moved.)
|
// card ever ringing itself while it's the one being moved.)
|
||||||
const isDropTarget = isOver && !isDragging;
|
const isDropTarget = isOver && !isDragging;
|
||||||
|
|
||||||
// The group's color for the ACTIVE theme: themes with their own palette
|
const color = getGroupColor(group.color);
|
||||||
// (cyberpunk, blueprint, vaporwave, notebook, starfield) retint the card,
|
|
||||||
// every other theme keeps the base GROUP_COLORS. Falls back to the base
|
|
||||||
// color pre-mount so the server render matches.
|
|
||||||
const color = useGroupColor(group.color);
|
|
||||||
const borderColor = isDark ? color.dark : color.light;
|
const borderColor = isDark ? color.dark : color.light;
|
||||||
// The card fill is a calm, hand-picked tint of the same stroke color
|
// The card fill is a calm, hand-picked tint of the same stroke color
|
||||||
// (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
|
// (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
|
||||||
|
|
@ -223,7 +186,6 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
data-group-card=""
|
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
|
|
@ -374,8 +336,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 +357,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 +401,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 +416,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 +430,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} />
|
||||||
|
|
|
||||||
|
|
@ -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,35 +17,19 @@ 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 { 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";
|
||||||
|
|
||||||
function Board({
|
function Board({ title }: { title: string }) {
|
||||||
title,
|
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
||||||
projectId,
|
|
||||||
aiConfigured,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
projectId?: string;
|
|
||||||
aiConfigured: boolean;
|
|
||||||
}) {
|
|
||||||
const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = 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 +51,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 +60,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 +119,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 +127,14 @@ 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 className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1">
|
||||||
data-board-chrome=""
|
|
||||||
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<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"
|
||||||
|
|
@ -226,7 +151,6 @@ function Board({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{aiConfigured && <SummaryButton projectId={projectId} />}
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
|
|
@ -236,10 +160,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 +175,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 +188,6 @@ function Board({
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
<AddCategoryLane />
|
<AddCategoryLane />
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -324,7 +229,7 @@ export function KanbanBoard({
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
>
|
>
|
||||||
<Board title={title} projectId={projectId} aiConfigured={aiConfigured} />
|
<Board title={title} />
|
||||||
</BoardProvider>
|
</BoardProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Check, Copy, FileText, Loader2, Sparkles } from "lucide-react";
|
|
||||||
|
|
||||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverDescription,
|
|
||||||
PopoverHeader,
|
|
||||||
PopoverTitle,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
|
||||||
import { summarizeBoard } from "@/lib/actions/summarize";
|
|
||||||
import { toDateKey } from "@/lib/dates";
|
|
||||||
|
|
||||||
const DATE_RANGE_OPTIONS = [
|
|
||||||
{ value: "today", label: "Today" },
|
|
||||||
{ value: "thisWeek", label: "This Week" },
|
|
||||||
{ value: "thisMonth", label: "This Month" },
|
|
||||||
{ value: "lastMonth", label: "Last Month" },
|
|
||||||
{ value: "custom", label: "Custom Range" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ORGANIZE_BY_OPTIONS = [
|
|
||||||
{ value: "byDate", label: "By Date" },
|
|
||||||
{ value: "byCategory", label: "By Category/Group" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type DateRange = (typeof DATE_RANGE_OPTIONS)[number]["value"];
|
|
||||||
type OrganizeBy = (typeof ORGANIZE_BY_OPTIONS)[number]["value"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Board header's "Summary" button: a compact popover with two dropdowns
|
|
||||||
* (date range + how to organize) and a Summarize action, then a
|
|
||||||
* result dialog that renders the AI's markdown recap with a copy-to-
|
|
||||||
* clipboard icon. Scoped to whatever board it's mounted on (Home or a
|
|
||||||
* Project) via `projectId`.
|
|
||||||
*/
|
|
||||||
export function SummaryButton({ projectId }: { projectId?: string }) {
|
|
||||||
const colorMode = colorModeFromTheme();
|
|
||||||
|
|
||||||
// Popover (selection) state.
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("today");
|
|
||||||
const [organizeBy, setOrganizeBy] = useState<OrganizeBy>("byDate");
|
|
||||||
// Both default to today so a "Custom Range" pick is immediately usable.
|
|
||||||
const [customStart, setCustomStart] = useState(() => toDateKey(new Date()));
|
|
||||||
const [customEnd, setCustomEnd] = useState(() => toDateKey(new Date()));
|
|
||||||
|
|
||||||
// Result dialog state.
|
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [noWork, setNoWork] = useState(false);
|
|
||||||
const [summary, setSummary] = useState<string | null>(null);
|
|
||||||
const [rangeLabel, setRangeLabel] = useState<string | null>(null);
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const customInvalid =
|
|
||||||
dateRange === "custom" && (!customStart || !customEnd || customStart > customEnd);
|
|
||||||
|
|
||||||
async function handleSummarize() {
|
|
||||||
if (generating || customInvalid) return;
|
|
||||||
setPopoverOpen(false);
|
|
||||||
setResultOpen(true);
|
|
||||||
setGenerating(true);
|
|
||||||
setError(null);
|
|
||||||
setNoWork(false);
|
|
||||||
setSummary(null);
|
|
||||||
setRangeLabel(null);
|
|
||||||
setCount(0);
|
|
||||||
setCopied(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await summarizeBoard(projectId ?? null, {
|
|
||||||
dateRange,
|
|
||||||
customStart: dateRange === "custom" ? customStart : undefined,
|
|
||||||
customEnd: dateRange === "custom" ? customEnd : undefined,
|
|
||||||
organizeBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === "ok") {
|
|
||||||
setSummary(result.content);
|
|
||||||
setRangeLabel(result.rangeLabel);
|
|
||||||
setCount(result.count);
|
|
||||||
} else if (result.status === "empty") {
|
|
||||||
setRangeLabel(result.rangeLabel);
|
|
||||||
setNoWork(true);
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starts fresh every time it's reopened -- a summary from a different
|
|
||||||
// range would be stale the moment the dialog closes and reopens.
|
|
||||||
function handleResultOpenChange(next: boolean) {
|
|
||||||
if (!next) {
|
|
||||||
setGenerating(false);
|
|
||||||
setError(null);
|
|
||||||
setNoWork(false);
|
|
||||||
setSummary(null);
|
|
||||||
setRangeLabel(null);
|
|
||||||
setCount(0);
|
|
||||||
setCopied(false);
|
|
||||||
}
|
|
||||||
setResultOpen(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopy() {
|
|
||||||
if (!summary) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(summary);
|
|
||||||
setCopied(true);
|
|
||||||
toast.success("Summary copied to clipboard.");
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
toast.error("Couldn't copy to clipboard.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" aria-label="Summarize completed work">
|
|
||||||
<FileText className="size-3.5" />
|
|
||||||
<span className="hidden md:inline">Summary</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="end" className="w-72">
|
|
||||||
<PopoverHeader>
|
|
||||||
<PopoverTitle>Summarize completed work</PopoverTitle>
|
|
||||||
<PopoverDescription>An AI recap of what you got done on this board.</PopoverDescription>
|
|
||||||
</PopoverHeader>
|
|
||||||
|
|
||||||
<div className="space-y-3.5">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="summary-date-range">Date range</Label>
|
|
||||||
<Select
|
|
||||||
value={dateRange}
|
|
||||||
// Without `items`, <Select.Value> can't look up a label for
|
|
||||||
// the current value until the popup has opened once.
|
|
||||||
items={DATE_RANGE_OPTIONS}
|
|
||||||
onValueChange={(v) => v && setDateRange(v as DateRange)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="summary-date-range" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DATE_RANGE_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dateRange === "custom" && (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="summary-custom-start">Start</Label>
|
|
||||||
<Input
|
|
||||||
id="summary-custom-start"
|
|
||||||
type="date"
|
|
||||||
value={customStart}
|
|
||||||
max={customEnd || undefined}
|
|
||||||
onChange={(e) => setCustomStart(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="summary-custom-end">End</Label>
|
|
||||||
<Input
|
|
||||||
id="summary-custom-end"
|
|
||||||
type="date"
|
|
||||||
value={customEnd}
|
|
||||||
min={customStart || undefined}
|
|
||||||
onChange={(e) => setCustomEnd(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="summary-organize-by">Organize by</Label>
|
|
||||||
<Select
|
|
||||||
value={organizeBy}
|
|
||||||
items={ORGANIZE_BY_OPTIONS}
|
|
||||||
onValueChange={(v) => v && setOrganizeBy(v as OrganizeBy)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="summary-organize-by" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ORGANIZE_BY_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{customInvalid && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
Pick a start date on or before the end date.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="w-full gap-2"
|
|
||||||
disabled={generating || customInvalid}
|
|
||||||
onClick={handleSummarize}
|
|
||||||
>
|
|
||||||
{generating ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="size-4" />
|
|
||||||
)}
|
|
||||||
Summarize
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<Dialog open={resultOpen} onOpenChange={handleResultOpenChange}>
|
|
||||||
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-xl" data-color-mode={colorMode}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
Summary
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{rangeLabel
|
|
||||||
? `${rangeLabel} · ${count} completed ${count === 1 ? "item" : "items"}`
|
|
||||||
: "An AI recap of what you got done."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{generating && (
|
|
||||||
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed p-10">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground">Summarizing your completed work…</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!generating && error && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setResultOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleSummarize}>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!generating && !error && noWork && (
|
|
||||||
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
||||||
Nothing was completed in this range yet -- check something off first.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!generating && !error && !noWork && summary && (
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border p-4">
|
|
||||||
<MarkdownPreview source={summary} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{summary && (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={copied ? "Copied" : "Copy summary to clipboard"}
|
|
||||||
onClick={handleCopy}
|
|
||||||
>
|
|
||||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent side="top">{copied ? "Copied" : "Copy to clipboard"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setResultOpen(false)}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -32,7 +32,6 @@ export function TodoCheckbox({
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
data-todo-checkbox=""
|
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
style={{
|
style={{
|
||||||
borderColor: accentColor,
|
borderColor: accentColor,
|
||||||
|
|
|
||||||
|
|
@ -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%)`,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, 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, 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";
|
||||||
import { isTimeDuePast } from "@/lib/time-of-day";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
@ -16,14 +14,8 @@ import { useScheduledPanel } from "@/components/scheduled/scheduled-panel-provid
|
||||||
import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item";
|
import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item";
|
||||||
import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
|
import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
|
||||||
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
|
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
|
||||||
import { useHoldOnComplete, type HoldPhase } from "@/components/hold-on-complete";
|
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import { useScheduledNotifications } from "@/components/scheduled/use-scheduled-notifications";
|
import type { ScheduledBoardDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
|
||||||
import type { ScheduledBoardDTO, ScheduledDayDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
|
|
||||||
|
|
||||||
// How often the client re-checks whether any of today's occurrences have
|
|
||||||
// crossed their time due -- fine-grained enough that "overdue" shows up
|
|
||||||
// within a minute of actually being overdue, without polling constantly.
|
|
||||||
const TIME_DUE_CHECK_INTERVAL_MS = 60_000;
|
|
||||||
|
|
||||||
function occurrenceKey(scheduledTodoId: string, occurrenceDate: string) {
|
function occurrenceKey(scheduledTodoId: string, occurrenceDate: string) {
|
||||||
return `${scheduledTodoId}-${occurrenceDate}`;
|
return `${scheduledTodoId}-${occurrenceDate}`;
|
||||||
|
|
@ -42,38 +34,6 @@ function matchesOccurrence(o: ScheduledOccurrenceDTO, scheduledTodoId: string, o
|
||||||
return o.scheduledTodoId === scheduledTodoId && o.occurrenceDate === occurrenceDate;
|
return o.scheduledTodoId === scheduledTodoId && o.occurrenceDate === occurrenceDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Promotes today's own occurrences into Overdue once their time due has
|
|
||||||
* passed, without waiting for the date to roll over -- the server only
|
|
||||||
* buckets by date (it doesn't know the viewer's timezone), so same-day
|
|
||||||
* time comparisons happen here against the browser's own clock instead.
|
|
||||||
* `holds` keeps a freshly-checked-off one here through its fade, same as
|
|
||||||
* ExpandedPanel's own visibleOverdue filter does for "real" overdue rows
|
|
||||||
* (see useHoldOnComplete) -- once the hold ends it settles back into
|
|
||||||
* `remaining` for good, indistinguishable from any other completed
|
|
||||||
* Today item.
|
|
||||||
*/
|
|
||||||
function splitTodayByTimeDue(
|
|
||||||
today: ScheduledDayDTO,
|
|
||||||
now: Date,
|
|
||||||
holds: Map<string, HoldPhase>
|
|
||||||
): { overdueToday: ScheduledOccurrenceDTO[]; remaining: ScheduledOccurrenceDTO[] } {
|
|
||||||
const overdueToday: ScheduledOccurrenceDTO[] = [];
|
|
||||||
const remaining: ScheduledOccurrenceDTO[] = [];
|
|
||||||
|
|
||||||
for (const o of today.occurrences) {
|
|
||||||
const pastDue = !!o.timeDue && isTimeDuePast(today.date, o.timeDue, now);
|
|
||||||
const key = occurrenceKey(o.scheduledTodoId, o.occurrenceDate);
|
|
||||||
if (pastDue && (!o.completed || holds.has(key))) {
|
|
||||||
overdueToday.push(o);
|
|
||||||
} else {
|
|
||||||
remaining.push(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { overdueToday, remaining };
|
|
||||||
}
|
|
||||||
|
|
||||||
function withToggledOccurrence(
|
function withToggledOccurrence(
|
||||||
board: ScheduledBoardDTO,
|
board: ScheduledBoardDTO,
|
||||||
scheduledTodoId: string,
|
scheduledTodoId: string,
|
||||||
|
|
@ -103,10 +63,7 @@ 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
|
const { beginHold, cancelHold } = useHoldOnComplete();
|
||||||
// `dialogOpen` -- that one is the add/edit form dialog.
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
|
||||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
getScheduledBoard(projectId)
|
getScheduledBoard(projectId)
|
||||||
|
|
@ -119,50 +76,6 @@ export function ScheduledPanel() {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Ticks on its own so a today occurrence with a time due crosses into
|
|
||||||
// Overdue live, without needing a click or a page refresh to notice.
|
|
||||||
const [now, setNow] = useState(() => new Date());
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => setNow(new Date()), TIME_DUE_CHECK_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// The board actually rendered -- board.today with anything past its time
|
|
||||||
// due moved into board.overdue (see splitTodayByTimeDue). Recomputed from
|
|
||||||
// the raw board on every render rather than stored, so toggling an
|
|
||||||
// occurrence or the clock ticking forward both stay in sync automatically.
|
|
||||||
const displayBoard = useMemo<ScheduledBoardDTO | null>(() => {
|
|
||||||
if (!board) return null;
|
|
||||||
const { overdueToday, remaining } = splitTodayByTimeDue(board.today, now, holds);
|
|
||||||
return {
|
|
||||||
...board,
|
|
||||||
overdue: [...board.overdue, ...overdueToday],
|
|
||||||
today: { ...board.today, occurrences: remaining },
|
|
||||||
};
|
|
||||||
}, [board, now, holds]);
|
|
||||||
|
|
||||||
// Fed the raw board (not displayBoard) -- it does its own pastDue check
|
|
||||||
// against board.today directly, independent of the hold-aware promotion
|
|
||||||
// above.
|
|
||||||
const notifications = useScheduledNotifications(board, now);
|
|
||||||
|
|
||||||
async function handleToggleNotifications() {
|
|
||||||
if (notifications.enabled) {
|
|
||||||
notifications.disable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (notifications.permission === "denied") {
|
|
||||||
toast.error("Notifications are blocked for this site -- enable them in your browser's site settings.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const granted = await notifications.requestEnable();
|
|
||||||
if (granted) {
|
|
||||||
toast.success("You'll get a notification when a scheduled to-do's time due passes.");
|
|
||||||
} else {
|
|
||||||
toast.error("Notification permission wasn't granted.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggle(occurrence: ScheduledOccurrenceDTO, completed: boolean) {
|
async function handleToggle(occurrence: ScheduledOccurrenceDTO, completed: boolean) {
|
||||||
setBoard((prev) =>
|
setBoard((prev) =>
|
||||||
prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : prev
|
prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : prev
|
||||||
|
|
@ -194,20 +107,18 @@ export function ScheduledPanel() {
|
||||||
|
|
||||||
// Reflects true remaining work, not the hold-delayed view -- the
|
// Reflects true remaining work, not the hold-delayed view -- the
|
||||||
// collapsed rail's badge should drop the instant something's checked off,
|
// collapsed rail's badge should drop the instant something's checked off,
|
||||||
// even while Overdue's own list still shows it fading out. Counted off
|
// even while Overdue's own list still shows it fading out.
|
||||||
// displayBoard so a today item past its time due counts as overdue here
|
const overdueCount = board?.overdue.filter((o) => !o.completed).length ?? 0;
|
||||||
// too, not as still-due-today.
|
|
||||||
const overdueCount = displayBoard?.overdue.filter((o) => !o.completed).length ?? 0;
|
|
||||||
// Today's occurrences list includes already-checked-off ones (so they can
|
// Today's occurrences list includes already-checked-off ones (so they can
|
||||||
// still be toggled back), unlike overdue -- so this needs its own filter
|
// still be toggled back), unlike overdue -- so this needs its own filter
|
||||||
// rather than just `board.today.occurrences.length`.
|
// rather than just `board.today.occurrences.length`.
|
||||||
const todayCount = displayBoard?.today.occurrences.filter((o) => !o.completed).length ?? 0;
|
const todayCount = board?.today.occurrences.filter((o) => !o.completed).length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -220,89 +131,15 @@ export function ScheduledPanel() {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ExpandedPanel
|
<ExpandedPanel
|
||||||
board={displayBoard}
|
board={board}
|
||||||
onToggleOccurrence={handleToggle}
|
onToggleOccurrence={handleToggle}
|
||||||
onEditOccurrence={handleEdit}
|
onEditOccurrence={handleEdit}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onCollapse={toggle}
|
onCollapse={toggle}
|
||||||
notificationsSupported={notifications.supported}
|
|
||||||
notificationsEnabled={notifications.enabled}
|
|
||||||
onToggleNotifications={handleToggleNotifications}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</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}
|
||||||
|
|
@ -417,18 +254,12 @@ function ExpandedPanel({
|
||||||
onEditOccurrence,
|
onEditOccurrence,
|
||||||
onAdd,
|
onAdd,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
notificationsSupported,
|
|
||||||
notificationsEnabled,
|
|
||||||
onToggleNotifications,
|
|
||||||
}: {
|
}: {
|
||||||
board: ScheduledBoardDTO | null;
|
board: ScheduledBoardDTO | null;
|
||||||
onToggleOccurrence: (occurrence: ScheduledOccurrenceDTO, completed: boolean) => void;
|
onToggleOccurrence: (occurrence: ScheduledOccurrenceDTO, completed: boolean) => void;
|
||||||
onEditOccurrence: (scheduledTodoId: string) => void;
|
onEditOccurrence: (scheduledTodoId: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
notificationsSupported: boolean;
|
|
||||||
notificationsEnabled: boolean;
|
|
||||||
onToggleNotifications: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { holds } = useHoldOnComplete();
|
const { holds } = useHoldOnComplete();
|
||||||
|
|
||||||
|
|
@ -452,30 +283,6 @@ function ExpandedPanel({
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span>
|
<span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
{notificationsSupported && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onToggleNotifications}
|
|
||||||
aria-label={notificationsEnabled ? "Turn off notifications" : "Turn on notifications"}
|
|
||||||
>
|
|
||||||
{notificationsEnabled ? (
|
|
||||||
<Bell className="size-4" />
|
|
||||||
) : (
|
|
||||||
<BellOff className="size-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
{notificationsEnabled ? "Notifications on" : "Notify me when something's due"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -485,11 +292,10 @@ function ExpandedPanel({
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Trash2, X } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -33,7 +33,6 @@ import {
|
||||||
updateScheduledTodo,
|
updateScheduledTodo,
|
||||||
} from "@/lib/actions/scheduled-todos";
|
} from "@/lib/actions/scheduled-todos";
|
||||||
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
|
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
|
||||||
import { REMIND_OPTIONS } from "@/lib/time-of-day";
|
|
||||||
|
|
||||||
const TITLE_MAX = 100;
|
const TITLE_MAX = 100;
|
||||||
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||||
|
|
@ -75,14 +74,6 @@ export function ScheduledTodoDialog({
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [details, setDetails] = useState("");
|
const [details, setDetails] = useState("");
|
||||||
const [startDate, setStartDate] = useState(todayDateKey());
|
const [startDate, setStartDate] = useState(todayDateKey());
|
||||||
// "HH:MM" (24-hour) or "" for no time due -- exactly what <input
|
|
||||||
// type="time"> reads and writes, so it needs no reformatting either way.
|
|
||||||
const [timeDue, setTimeDue] = useState("");
|
|
||||||
// Only meaningful (and only shown, enabled) while timeDue is set --
|
|
||||||
// defaults to "When due" the moment a time gets set, since that's what a
|
|
||||||
// to-do with a time due already did before "Remind me" existed. Null =
|
|
||||||
// "Don't remind me", picked explicitly.
|
|
||||||
const [remindMinutesBefore, setRemindMinutesBefore] = useState<number | null>(0);
|
|
||||||
const [isRecurring, setIsRecurring] = useState(false);
|
const [isRecurring, setIsRecurring] = useState(false);
|
||||||
const [frequency, setFrequency] = useState<RecurrenceInput["frequency"]>("WEEKLY");
|
const [frequency, setFrequency] = useState<RecurrenceInput["frequency"]>("WEEKLY");
|
||||||
const [interval, setInterval] = useState(1);
|
const [interval, setInterval] = useState(1);
|
||||||
|
|
@ -104,8 +95,6 @@ export function ScheduledTodoDialog({
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDetails("");
|
setDetails("");
|
||||||
setStartDate(todayDateKey());
|
setStartDate(todayDateKey());
|
||||||
setTimeDue("");
|
|
||||||
setRemindMinutesBefore(0);
|
|
||||||
setIsRecurring(false);
|
setIsRecurring(false);
|
||||||
setFrequency("WEEKLY");
|
setFrequency("WEEKLY");
|
||||||
setInterval(1);
|
setInterval(1);
|
||||||
|
|
@ -124,8 +113,6 @@ export function ScheduledTodoDialog({
|
||||||
setTitle(todo.title);
|
setTitle(todo.title);
|
||||||
setDetails(todo.details ?? "");
|
setDetails(todo.details ?? "");
|
||||||
setStartDate(todo.startDate);
|
setStartDate(todo.startDate);
|
||||||
setTimeDue(todo.timeDue ?? "");
|
|
||||||
setRemindMinutesBefore(todo.timeDue ? todo.remindMinutesBefore : 0);
|
|
||||||
const recurrence = todo.recurrence;
|
const recurrence = todo.recurrence;
|
||||||
setIsRecurring(!!recurrence);
|
setIsRecurring(!!recurrence);
|
||||||
setFrequency(recurrence?.frequency ?? "WEEKLY");
|
setFrequency(recurrence?.frequency ?? "WEEKLY");
|
||||||
|
|
@ -183,20 +170,11 @@ export function ScheduledTodoDialog({
|
||||||
details: details.trim() || null,
|
details: details.trim() || null,
|
||||||
startDate,
|
startDate,
|
||||||
recurrence: buildRecurrence() ?? null,
|
recurrence: buildRecurrence() ?? null,
|
||||||
timeDue: timeDue || null,
|
|
||||||
remindMinutesBefore: timeDue ? remindMinutesBefore : null,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createScheduledTodo(
|
await createScheduledTodo(
|
||||||
{ projectId },
|
{ projectId },
|
||||||
{
|
{ title: trimmedTitle, details: details.trim() || undefined, startDate, recurrence: buildRecurrence() }
|
||||||
title: trimmedTitle,
|
|
||||||
details: details.trim() || undefined,
|
|
||||||
startDate,
|
|
||||||
recurrence: buildRecurrence(),
|
|
||||||
timeDue: timeDue || undefined,
|
|
||||||
remindMinutesBefore: timeDue ? remindMinutesBefore : null,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
|
|
@ -257,8 +235,7 @@ export function ScheduledTodoDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="space-y-2">
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Label htmlFor="scheduled-date">
|
<Label htmlFor="scheduled-date">
|
||||||
{isRecurring ? "Starts on" : "Date"}
|
{isRecurring ? "Starts on" : "Date"}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
@ -270,60 +247,6 @@ export function ScheduledTodoDialog({
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Label htmlFor="scheduled-time-due">Time due (optional)</Label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* Native time input: its own picker already steps by
|
|
||||||
15 minutes (`step`) and it's freely typable, so no
|
|
||||||
custom combobox is needed for either requirement. */}
|
|
||||||
<Input
|
|
||||||
id="scheduled-time-due"
|
|
||||||
type="time"
|
|
||||||
step={60 * 15}
|
|
||||||
value={timeDue}
|
|
||||||
onChange={(e) => setTimeDue(e.target.value)}
|
|
||||||
/>
|
|
||||||
{timeDue && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setTimeDue("")}
|
|
||||||
aria-label="Clear time due"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="scheduled-remind">Remind me</Label>
|
|
||||||
<Select
|
|
||||||
value={remindMinutesBefore === null ? "none" : String(remindMinutesBefore)}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
const option = REMIND_OPTIONS.find((o) => o.value === v);
|
|
||||||
if (option) setRemindMinutesBefore(option.minutesBefore);
|
|
||||||
}}
|
|
||||||
disabled={!timeDue}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="scheduled-remind" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{REMIND_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{!timeDue && (
|
|
||||||
<p className="text-xs text-muted-foreground">Set a time due to enable reminders.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium">
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Checkbox checked={isRecurring} onCheckedChange={(c) => setIsRecurring(!!c)} />
|
<Checkbox checked={isRecurring} onCheckedChange={(c) => setIsRecurring(!!c)} />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { StickyNote } from "lucide-react";
|
import { StickyNote } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatTimeDue } from "@/lib/time-of-day";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
|
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
|
||||||
import type { HoldPhase } from "@/components/hold-on-complete";
|
import type { HoldPhase } from "@/components/hold-on-complete";
|
||||||
|
|
@ -68,16 +67,6 @@ export function ScheduledTodoItem({
|
||||||
) : (
|
) : (
|
||||||
titleButton
|
titleButton
|
||||||
)}
|
)}
|
||||||
{occurrence.timeDue && !occurrence.completed && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"mt-0.5 shrink-0 text-xs",
|
|
||||||
emphasis === "overdue" ? "font-semibold text-destructive" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatTimeDue(occurrence.timeDue)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{occurrence.details && (
|
{occurrence.details && (
|
||||||
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
|
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { formatTimeDue, isReminderDue } from "@/lib/time-of-day";
|
|
||||||
import type { ScheduledBoardDTO } from "@/types/scheduled-todo";
|
|
||||||
|
|
||||||
// Separate from the panel's own collapsed-state key (scheduled-panel-provider.tsx).
|
|
||||||
const STORAGE_KEY = "organize:scheduled-notifications-enabled";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Browser `Notification`s for scheduled to-dos, fired per each one's own
|
|
||||||
* "Remind me" lead time (see lib/time-of-day.ts's REMIND_OPTIONS) -- which
|
|
||||||
* may be before its time due, not necessarily at the same moment it's
|
|
||||||
* promoted into Overdue (see ScheduledPanel; that's driven by timeDue
|
|
||||||
* itself, unaffected by the reminder offset). This only reaches the user
|
|
||||||
* while some copy of this tab is open somewhere (foreground or
|
|
||||||
* backgrounded); there's no service worker `push` handler behind it, so
|
|
||||||
* nothing fires once the tab itself is closed. That would need real Web
|
|
||||||
* Push infrastructure (VAPID keys, a subscription table, a server-side
|
|
||||||
* sender) -- a bigger step this intentionally doesn't take.
|
|
||||||
*/
|
|
||||||
export function useScheduledNotifications(board: ScheduledBoardDTO | null, now: Date) {
|
|
||||||
// Whether the user has opted in *within the app* -- distinct from the
|
|
||||||
// browser's own permission, which can only be granted or revoked through
|
|
||||||
// its own UI, never by us. Same hydration-safe pattern as
|
|
||||||
// ScheduledPanelProvider: default off on both server and first client
|
|
||||||
// render, then read the persisted value (and the live permission) right
|
|
||||||
// after mount.
|
|
||||||
const [enabled, setEnabled] = useState(false);
|
|
||||||
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!("Notification" in window)) return;
|
|
||||||
setPermission(Notification.permission);
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
// Only actually on if the browser still agrees -- a permission the user
|
|
||||||
// revoked from browser settings since last time shouldn't look enabled.
|
|
||||||
setEnabled(stored === "true" && Notification.permission === "granted");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestEnable = useCallback(async () => {
|
|
||||||
if (!("Notification" in window)) return false;
|
|
||||||
let result = Notification.permission;
|
|
||||||
if (result === "default") {
|
|
||||||
result = await Notification.requestPermission();
|
|
||||||
setPermission(result);
|
|
||||||
}
|
|
||||||
const granted = result === "granted";
|
|
||||||
if (granted) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, "true");
|
|
||||||
setEnabled(true);
|
|
||||||
}
|
|
||||||
return granted;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const disable = useCallback(() => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, "false");
|
|
||||||
setEnabled(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Occurrences already notified about this session, so the once-a-minute
|
|
||||||
// tick in ScheduledPanel doesn't re-fire the same notification every time
|
|
||||||
// it re-checks. Keyed the same way as everywhere else in this panel;
|
|
||||||
// never pruned since occurrenceDate makes each key unique per calendar
|
|
||||||
// day anyway, and the set only lives as long as the tab does.
|
|
||||||
const notifiedRef = useRef<Set<string> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !board) return;
|
|
||||||
|
|
||||||
const dueForReminder = board.today.occurrences.filter(
|
|
||||||
(o) =>
|
|
||||||
!o.completed &&
|
|
||||||
o.timeDue &&
|
|
||||||
o.remindMinutesBefore !== null &&
|
|
||||||
isReminderDue(board.today.date, o.timeDue, o.remindMinutesBefore, now)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (notifiedRef.current === null) {
|
|
||||||
// First run after mounting (or reconnecting to a fresh board) --
|
|
||||||
// baseline whatever's already due for a reminder instead of
|
|
||||||
// notifying for old news the Overdue section (or an already-seen
|
|
||||||
// reminder) is already showing.
|
|
||||||
notifiedRef.current = new Set(dueForReminder.map((o) => `${o.scheduledTodoId}-${o.occurrenceDate}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const o of dueForReminder) {
|
|
||||||
const key = `${o.scheduledTodoId}-${o.occurrenceDate}`;
|
|
||||||
if (notifiedRef.current.has(key)) continue;
|
|
||||||
notifiedRef.current.add(key);
|
|
||||||
|
|
||||||
const notification = new Notification(o.title, {
|
|
||||||
body: o.timeDue ? `Due at ${formatTimeDue(o.timeDue)}` : undefined,
|
|
||||||
icon: "/icons/icon-192.png",
|
|
||||||
tag: key, // replaces rather than stacking if this somehow runs twice
|
|
||||||
});
|
|
||||||
notification.onclick = () => {
|
|
||||||
window.focus();
|
|
||||||
notification.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [enabled, board, now]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// "unsupported" hides the toggle entirely -- nothing to offer.
|
|
||||||
supported: permission !== "unsupported",
|
|
||||||
enabled,
|
|
||||||
permission,
|
|
||||||
requestEnable,
|
|
||||||
disable,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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).
|
||||||
*
|
*
|
||||||
|
|
@ -36,34 +22,25 @@ export type { ThemeName, RawTheme };
|
||||||
* it renders as plain server HTML and actually executes.
|
* it renders as plain server HTML and actually executes.
|
||||||
*
|
*
|
||||||
* 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: default | sunset | dark | ocean | system
|
||||||
* - `resolvedTheme` the theme actually applied, always one of THEMES
|
* - `resolvedTheme` the theme actually applied (system resolved via
|
||||||
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
|
* prefers-color-scheme), always one of the four
|
||||||
*
|
* - html classes .theme-default | .theme-sunset | .dark | .ocean (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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export const THEMES = ["default", "sunset", "dark", "ocean"] 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",
|
||||||
|
dark: "dark",
|
||||||
|
ocean: "ocean",
|
||||||
|
};
|
||||||
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
||||||
|
const DARK_SURFACES: ThemeName[] = ["dark", "ocean"];
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: RawTheme;
|
theme: RawTheme;
|
||||||
|
|
@ -72,21 +49,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 +74,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 +132,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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,42 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Monitor,
|
|
||||||
Check,
|
|
||||||
Sun,
|
|
||||||
Moon,
|
|
||||||
Sunset,
|
|
||||||
Waves,
|
|
||||||
Leaf,
|
|
||||||
Hexagon,
|
|
||||||
Rose,
|
|
||||||
Flower2,
|
|
||||||
Mountain,
|
|
||||||
TreePine,
|
|
||||||
Grape,
|
|
||||||
Star,
|
|
||||||
Flame,
|
|
||||||
Wine,
|
|
||||||
DraftingCompass,
|
|
||||||
CircuitBoard,
|
|
||||||
Disc3,
|
|
||||||
Orbit,
|
|
||||||
NotebookText,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { useTheme } from "@/components/theme/theme-provider";
|
import { useTheme } from "@/components/theme/theme-provider";
|
||||||
import type { ThemeName } from "@/components/theme/theme-provider";
|
import { Moon, Sun, Monitor, Sunset, Waves, Check } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 = {
|
// The four looks, shown as two "modes" with a shared System row. Each entry
|
||||||
value: ThemeName;
|
// is one next-themes value (see app/layout.tsx for the class mapping); the
|
||||||
label: string;
|
// little dot is the theme's own accent so the picker previews the vibe.
|
||||||
icon: LucideIcon;
|
const OPTIONS = [
|
||||||
swatch: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The looks, grouped by surface. Each value is one next-themes value (see
|
|
||||||
// app/layout.tsx for the class mapping); the little dot is the theme's own
|
|
||||||
// accent so the picker previews the vibe. Exported for reuse by other
|
|
||||||
// theme pickers (e.g. components/projects/project-theme-picker.tsx).
|
|
||||||
export const LIGHT_OPTIONS: ThemeOption[] = [
|
|
||||||
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
|
{ value: "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: "honey", label: "Honey", icon: Hexagon, swatch: "#d97706" },
|
|
||||||
{ value: "rose", label: "Rosé", icon: Rose, swatch: "#e11d48" },
|
|
||||||
{ value: "lavender", label: "Lavender", icon: Flower2, swatch: "#7c3aed" },
|
|
||||||
{ value: "slate", label: "Slate", icon: Mountain, swatch: "#4a6d9c" },
|
|
||||||
{ value: "blueprint", label: "Blueprint", icon: DraftingCompass, swatch: "#2b4bc4" },
|
|
||||||
{ value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" },
|
|
||||||
{ value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" },
|
|
||||||
];
|
|
||||||
export 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" },
|
] as const;
|
||||||
{ value: "plum", label: "Plum", icon: Grape, swatch: "#c084fc" },
|
|
||||||
{ value: "midnight", label: "Midnight", icon: Star, swatch: "#93c5fd" },
|
|
||||||
{ value: "ember", label: "Ember", icon: Flame, swatch: "#fbbf24" },
|
|
||||||
{ value: "rosewood", label: "Rosewood", icon: Wine, swatch: "#fb7185" },
|
|
||||||
{ value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" },
|
|
||||||
{ value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" },
|
|
||||||
];
|
|
||||||
export const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
|
|
||||||
|
|
||||||
export function OptionRow({
|
const CURRENT_ICONS = {
|
||||||
option,
|
default: Sun,
|
||||||
active,
|
sunset: Sunset,
|
||||||
onSelect,
|
dark: Moon,
|
||||||
}: {
|
ocean: Waves,
|
||||||
option: ThemeOption;
|
system: Monitor,
|
||||||
active: boolean;
|
} as const;
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const Icon = 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 always shows the four real themes; "system" is handled by a
|
||||||
// handled by a dedicated row since it resolves to one of them per device
|
// dedicated row since it resolves to one of them per device preference.
|
||||||
// preference.
|
const known = OPTIONS.find((o) => o.value === theme);
|
||||||
const known = ALL_OPTIONS.find((o) => o.value === theme);
|
|
||||||
const isSystem = theme === "system" || theme === undefined;
|
const isSystem = theme === "system" || theme === undefined;
|
||||||
const Icon = (isSystem ? Monitor : known?.icon) ?? Monitor;
|
const Icon = (isSystem ? CURRENT_ICONS.system : known?.icon) ?? CURRENT_ICONS.system;
|
||||||
const label = isSystem ? "System" : known?.label ?? "Default";
|
const label = isSystem ? "System" : known?.label ?? "Default";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,34 +49,34 @@ export function ThemeToggle({
|
||||||
className={collapsed ? undefined : "w-full justify-start gap-2"}
|
className={collapsed ? undefined : "w-full justify-start gap-2"}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
{!collapsed && <span>{label}</span>}
|
{!collapsed && <span>Theme</span>}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DropdownMenuContent align={menuAlign} side={menuSide} className="w-52">
|
<DropdownMenuContent align="start" side="right" className="w-52">
|
||||||
<DropdownMenuLabel>Light</DropdownMenuLabel>
|
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
|
||||||
{LIGHT_OPTIONS.map((option) => (
|
{OPTIONS.map((option) => (
|
||||||
<OptionRow
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
option={option}
|
type="button"
|
||||||
active={theme === option.value}
|
onClick={() => setTheme(option.value)}
|
||||||
onSelect={() => setTheme(option.value)}
|
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"
|
||||||
|
>
|
||||||
|
<option.icon className="size-4 text-muted-foreground group-focus:text-current" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
||||||
|
style={{ backgroundColor: option.swatch }}
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
|
{theme === option.value && <Check className="absolute right-2 -mr-4 size-4" />}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>Dark</DropdownMenuLabel>
|
<button
|
||||||
{DARK_OPTIONS.map((option) => (
|
type="button"
|
||||||
<OptionRow
|
|
||||||
key={option.value}
|
|
||||||
option={option}
|
|
||||||
active={theme === option.value}
|
|
||||||
onSelect={() => setTheme(option.value)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
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 +87,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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useTheme, DARK_SURFACES } from "./theme-provider";
|
import { useTheme, resolveTheme } from "./theme-provider";
|
||||||
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): "dark" and
|
||||||
// provider's single source of truth -- currently Dark, Ocean, Pine, Plum,
|
// "ocean". Anything that used to compare `resolvedTheme === "dark"` to pick
|
||||||
// Midnight, Ember, Rosewood. Anything that used to compare
|
// a color variant (group card fills/strokes, markdown widget color modes,
|
||||||
// `resolvedTheme === "dark"` to pick a color variant (group card
|
// ...) uses this instead, so Ocean is treated exactly like Dark.
|
||||||
// fills/strokes, markdown widget color modes, ...) uses this instead.
|
const DARK_SURFACES = new Set(["dark", "ocean"]);
|
||||||
|
|
||||||
function useMounted(): boolean {
|
function useMounted(): boolean {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
@ -20,15 +19,12 @@ function useMounted(): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the active theme has dark surfaces (Dark, Ocean, Pine, Plum,
|
* True when the active theme has dark surfaces (Dark or Ocean).
|
||||||
* 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,42 +32,15 @@ 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.has(resolveTheme(theme));
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the "cyberpunk" theme is active. Used to swap the neon
|
|
||||||
* color palette onto group cards only for that theme.
|
|
||||||
*/
|
|
||||||
export function isCyberpunkTheme(): boolean {
|
|
||||||
const mounted = useMounted();
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
if (!mounted) return false;
|
|
||||||
return resolvedTheme === "cyberpunk";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The group color for the active theme. Themes with their own palette
|
|
||||||
* (cyberpunk, blueprint, vaporwave, notebook, starfield -- see
|
|
||||||
* lib/colors.ts) get theirs; everything else gets the base color. Same
|
|
||||||
* contract as isDarkTheme: reactive to theme switches (derived from the
|
|
||||||
* provider's state, so a setTheme re-renders every consumer in the same
|
|
||||||
* commit) and hydration-safe (until mounted we report the base color,
|
|
||||||
* matching the server render).
|
|
||||||
*/
|
|
||||||
export function useGroupColor(key: string): GroupColor {
|
|
||||||
const mounted = useMounted();
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
if (!mounted) return getGroupColor(key);
|
|
||||||
return getThemedGroupColor(resolvedTheme, key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `data-color-mode` value the markdown editor/preview widgets expect.
|
* The `data-color-mode` value the markdown editor/preview widgets expect.
|
||||||
* They only understand light/dark, so the light looks (default, sunset,
|
* They only understand light/dark, so the light "sunset" look reports as
|
||||||
* meadow, honey, rose, lavender, slate) report as light and the dark looks
|
* light and the dark "ocean" look reports as dark.
|
||||||
* report as dark.
|
|
||||||
*/
|
*/
|
||||||
export function colorModeFromTheme(): "light" | "dark" {
|
export function colorModeFromTheme(): "light" | "dark" {
|
||||||
return isDarkTheme() ? "dark" : "light";
|
return isDarkTheme() ? "dark" : "light";
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useTheme, DARK_SURFACES } from "@/components/theme/theme-provider"
|
import { useTheme } from "@/components/theme/theme-provider"
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
// Sonner only knows light/dark -- map the app's looks onto what it
|
// Sonner only knows light/dark -- map the app's looks (default/sunset are
|
||||||
// understands, using the provider's single dark-surface list.
|
// light; dark/ocean are dark) onto what it understands.
|
||||||
const theme: ToasterProps["theme"] = DARK_SURFACES.includes(resolvedTheme)
|
const theme: ToasterProps["theme"] =
|
||||||
? "dark"
|
resolvedTheme === "dark" || resolvedTheme === "ocean" ? "dark" : "light"
|
||||||
: "light"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
|
|
@ -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 {};
|
|
||||||
}
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { boardPath, projectAccessFilter, scheduledTodoAccessFilter } from "@/lib
|
||||||
import {
|
import {
|
||||||
RecurrenceInputSchema,
|
RecurrenceInputSchema,
|
||||||
ScheduledTodoDetailsSchema,
|
ScheduledTodoDetailsSchema,
|
||||||
ScheduledTodoRemindMinutesBeforeSchema,
|
|
||||||
ScheduledTodoTimeDueSchema,
|
|
||||||
ScheduledTodoTitleSchema,
|
ScheduledTodoTitleSchema,
|
||||||
type RecurrenceInput,
|
type RecurrenceInput,
|
||||||
} from "@/lib/validation/scheduled-todo";
|
} from "@/lib/validation/scheduled-todo";
|
||||||
|
|
@ -29,10 +27,7 @@ async function assertProjectAccess(userId: string, projectId: string) {
|
||||||
async function findOwnedScheduledTodo(userId: string, id: string) {
|
async function findOwnedScheduledTodo(userId: string, id: string) {
|
||||||
const todo = await prisma.scheduledTodo.findFirst({
|
const todo = await prisma.scheduledTodo.findFirst({
|
||||||
where: { id, ...scheduledTodoAccessFilter(userId) },
|
where: { id, ...scheduledTodoAccessFilter(userId) },
|
||||||
// timeDue is included so updateScheduledTodo can tell whether a
|
select: { id: true, projectId: true },
|
||||||
// reminder is still meaningful even when this particular call doesn't
|
|
||||||
// touch timeDue itself.
|
|
||||||
select: { id: true, projectId: true, timeDue: true },
|
|
||||||
});
|
});
|
||||||
if (!todo) throw new Error("Scheduled to-do not found");
|
if (!todo) throw new Error("Scheduled to-do not found");
|
||||||
return todo;
|
return todo;
|
||||||
|
|
@ -63,32 +58,17 @@ export async function getScheduledTodoForEdit(id: string): Promise<ScheduledTodo
|
||||||
details: todo.details,
|
details: todo.details,
|
||||||
startDate: todo.startDate.toISOString().slice(0, 10),
|
startDate: todo.startDate.toISOString().slice(0, 10),
|
||||||
recurrence: todo.rrule ? parseRRuleString(todo.rrule) : null,
|
recurrence: todo.rrule ? parseRRuleString(todo.rrule) : null,
|
||||||
timeDue: todo.timeDue,
|
|
||||||
remindMinutesBefore: todo.remindMinutesBefore,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScheduledTodo(
|
export async function createScheduledTodo(
|
||||||
scope: { projectId: string | null },
|
scope: { projectId: string | null },
|
||||||
data: {
|
data: { title: string; details?: string; startDate: string; recurrence?: RecurrenceInput }
|
||||||
title: string;
|
|
||||||
details?: string;
|
|
||||||
startDate: string;
|
|
||||||
recurrence?: RecurrenceInput;
|
|
||||||
timeDue?: string | null;
|
|
||||||
remindMinutesBefore?: number | null;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
const title = ScheduledTodoTitleSchema.parse(data.title);
|
const title = ScheduledTodoTitleSchema.parse(data.title);
|
||||||
const details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : undefined;
|
const details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : undefined;
|
||||||
const recurrence = data.recurrence ? RecurrenceInputSchema.parse(data.recurrence) : undefined;
|
const recurrence = data.recurrence ? RecurrenceInputSchema.parse(data.recurrence) : undefined;
|
||||||
const timeDue = data.timeDue ? ScheduledTodoTimeDueSchema.parse(data.timeDue) : null;
|
|
||||||
// No reminder without a time to count down from, regardless of what's passed.
|
|
||||||
const remindMinutesBefore =
|
|
||||||
timeDue && data.remindMinutesBefore != null
|
|
||||||
? ScheduledTodoRemindMinutesBeforeSchema.parse(data.remindMinutesBefore)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (scope.projectId) await assertProjectAccess(userId, scope.projectId);
|
if (scope.projectId) await assertProjectAccess(userId, scope.projectId);
|
||||||
|
|
||||||
|
|
@ -100,8 +80,6 @@ export async function createScheduledTodo(
|
||||||
projectId: scope.projectId,
|
projectId: scope.projectId,
|
||||||
startDate: new Date(data.startDate),
|
startDate: new Date(data.startDate),
|
||||||
rrule: recurrence ? buildRRuleString(recurrence) : null,
|
rrule: recurrence ? buildRRuleString(recurrence) : null,
|
||||||
timeDue,
|
|
||||||
remindMinutesBefore,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,8 +93,6 @@ export async function updateScheduledTodo(
|
||||||
details?: string | null;
|
details?: string | null;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
recurrence?: RecurrenceInput | null;
|
recurrence?: RecurrenceInput | null;
|
||||||
timeDue?: string | null;
|
|
||||||
remindMinutesBefore?: number | null;
|
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
|
@ -127,8 +103,6 @@ export async function updateScheduledTodo(
|
||||||
details?: string | null;
|
details?: string | null;
|
||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
rrule?: string | null;
|
rrule?: string | null;
|
||||||
timeDue?: string | null;
|
|
||||||
remindMinutesBefore?: number | null;
|
|
||||||
} = {};
|
} = {};
|
||||||
if (data.title !== undefined) update.title = ScheduledTodoTitleSchema.parse(data.title);
|
if (data.title !== undefined) update.title = ScheduledTodoTitleSchema.parse(data.title);
|
||||||
if (data.details !== undefined) {
|
if (data.details !== undefined) {
|
||||||
|
|
@ -138,24 +112,6 @@ export async function updateScheduledTodo(
|
||||||
if (data.recurrence !== undefined) {
|
if (data.recurrence !== undefined) {
|
||||||
update.rrule = data.recurrence ? buildRRuleString(RecurrenceInputSchema.parse(data.recurrence)) : null;
|
update.rrule = data.recurrence ? buildRRuleString(RecurrenceInputSchema.parse(data.recurrence)) : null;
|
||||||
}
|
}
|
||||||
if (data.timeDue !== undefined) {
|
|
||||||
update.timeDue = data.timeDue ? ScheduledTodoTimeDueSchema.parse(data.timeDue) : null;
|
|
||||||
}
|
|
||||||
// The time due this reminder would count down from, after this update --
|
|
||||||
// whatever this call sets it to, or (if this call doesn't touch it) the
|
|
||||||
// value already on the row. No reminder without one, regardless of what's
|
|
||||||
// passed.
|
|
||||||
const effectiveTimeDue = data.timeDue !== undefined ? update.timeDue : todo.timeDue;
|
|
||||||
if (data.remindMinutesBefore !== undefined) {
|
|
||||||
update.remindMinutesBefore =
|
|
||||||
effectiveTimeDue && data.remindMinutesBefore != null
|
|
||||||
? ScheduledTodoRemindMinutesBeforeSchema.parse(data.remindMinutesBefore)
|
|
||||||
: null;
|
|
||||||
} else if (!effectiveTimeDue && todo.timeDue) {
|
|
||||||
// Time due was just cleared and this call didn't say anything about
|
|
||||||
// the reminder -- don't leave a stale reminder pointing at nothing.
|
|
||||||
update.remindMinutesBefore = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.scheduledTodo.update({ where: { id }, data: update });
|
await prisma.scheduledTodo.update({ where: { id }, data: update });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { addUTCDays, getWeekStart, startOfUTCDate, toDateKey } from "@/lib/dates";
|
|
||||||
import { callChatCompletion } from "@/lib/ai/chat-completion";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { requireUserId } from "@/lib/auth-helpers";
|
|
||||||
import { SummaryRequestSchema, type SummaryRequest } from "@/lib/validation/summary";
|
|
||||||
|
|
||||||
export type BoardSummaryResult =
|
|
||||||
| { status: "ok"; content: string; rangeLabel: string; count: number }
|
|
||||||
| { status: "empty"; rangeLabel: string }
|
|
||||||
| { status: "error"; error: string };
|
|
||||||
|
|
||||||
// Hard prompt-size caps -- the same philosophy as lib/ai/context.ts: the
|
|
||||||
// whole relevant dataset goes straight into the prompt, truncated only if
|
|
||||||
// a huge custom range would blow the model's context window.
|
|
||||||
const DETAILS_CHAR_CAP = 300;
|
|
||||||
const TOTAL_CHAR_CAP = 40_000;
|
|
||||||
|
|
||||||
interface ResolvedRange {
|
|
||||||
// Half-open UTC interval: [from, to).
|
|
||||||
from: Date;
|
|
||||||
to: Date;
|
|
||||||
// Human label ("Aug 12, 2026", "This week: Aug 10 – Aug 16, 2026", ...)
|
|
||||||
// shown to the user and quoted in the AI prompt.
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUtcDay(date: Date, weekday: boolean): string {
|
|
||||||
return date.toLocaleDateString(
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
...(weekday ? { weekday: "long" as const } : {}),
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
// UTC because the boundaries were computed in UTC -- rendering in a
|
|
||||||
// different timezone could shift an edge day.
|
|
||||||
timeZone: "UTC",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function monthLabel(year: number, monthIndex: number): string {
|
|
||||||
return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString(undefined, {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All boundaries are UTC calendar days -- same convention as lib/dates.ts. */
|
|
||||||
function resolveRange(request: SummaryRequest, now: Date): ResolvedRange {
|
|
||||||
const year = now.getUTCFullYear();
|
|
||||||
const month = now.getUTCMonth(); // 0-based
|
|
||||||
|
|
||||||
switch (request.dateRange) {
|
|
||||||
case "today": {
|
|
||||||
const from = startOfUTCDate(now);
|
|
||||||
return { from, to: addUTCDays(from, 1), label: formatUtcDay(from, true) };
|
|
||||||
}
|
|
||||||
case "thisWeek": {
|
|
||||||
const from = getWeekStart(now);
|
|
||||||
const to = addUTCDays(from, 7);
|
|
||||||
return { from, to, label: `This week (${formatUtcDay(from, false)} – ${formatUtcDay(addUTCDays(to, -1), false)})` };
|
|
||||||
}
|
|
||||||
case "thisMonth": {
|
|
||||||
const from = new Date(Date.UTC(year, month, 1));
|
|
||||||
const to = new Date(Date.UTC(year, month + 1, 1));
|
|
||||||
return { from, to, label: `This month (${monthLabel(year, month)})` };
|
|
||||||
}
|
|
||||||
case "lastMonth": {
|
|
||||||
const from = new Date(Date.UTC(year, month - 1, 1));
|
|
||||||
const to = new Date(Date.UTC(year, month, 1));
|
|
||||||
return { from, to, label: `Last month (${monthLabel(year, month - 1)})` };
|
|
||||||
}
|
|
||||||
case "custom": {
|
|
||||||
// safeParse already guaranteed both dates exist when dateRange is "custom".
|
|
||||||
const from = startOfUTCDate(new Date(`${request.customStart}T00:00:00Z`));
|
|
||||||
const to = addUTCDays(startOfUTCDate(new Date(`${request.customEnd}T00:00:00Z`)), 1);
|
|
||||||
const end = addUTCDays(to, -1);
|
|
||||||
return { from, to, label: formatUtcDay(from, false) === formatUtcDay(end, false) ? formatUtcDay(from, true) : `${formatUtcDay(from, false)} – ${formatUtcDay(end, false)}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BoardTodoEntry {
|
|
||||||
title: string;
|
|
||||||
details: string | null;
|
|
||||||
completedAt: Date;
|
|
||||||
groupTitle: string;
|
|
||||||
categoryName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduledEntry {
|
|
||||||
title: string;
|
|
||||||
occurrenceDate: Date;
|
|
||||||
completedAt: Date;
|
|
||||||
recurring: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BoardSummaryData {
|
|
||||||
categories: {
|
|
||||||
name: string;
|
|
||||||
groups: { title: string; archived: boolean; todos: BoardTodoEntry[] }[];
|
|
||||||
}[];
|
|
||||||
scheduled: ScheduledEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetails(details: string | null): string {
|
|
||||||
const trimmed = details?.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (trimmed.length <= DETAILS_CHAR_CAP) return ` — ${trimmed}`;
|
|
||||||
return ` — ${trimmed.slice(0, DETAILS_CHAR_CAP)}… [truncated]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Groups everything by the day it belongs to, in the order the owner asked for. */
|
|
||||||
function formatByDate(data: BoardSummaryData): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
const boardFlat: (BoardTodoEntry & { category: string; group: string })[] =
|
|
||||||
data.categories.flatMap((category) =>
|
|
||||||
category.groups.flatMap((group) =>
|
|
||||||
group.todos.map((todo) => ({ ...todo, category: category.name, group: group.title }))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (boardFlat.length > 0) {
|
|
||||||
lines.push("Board to-dos, grouped by the day they were completed:");
|
|
||||||
const byDay = new Map<string, typeof boardFlat>();
|
|
||||||
for (const todo of boardFlat) {
|
|
||||||
const key = toDateKey(todo.completedAt);
|
|
||||||
const bucket = byDay.get(key);
|
|
||||||
if (bucket) bucket.push(todo);
|
|
||||||
else byDay.set(key, [todo]);
|
|
||||||
}
|
|
||||||
// Day keys are "YYYY-MM-DD", so lexicographic = chronological -- the
|
|
||||||
// model should see (and keep) oldest day first even though the
|
|
||||||
// underlying data is grouped by category/group.
|
|
||||||
for (const [key, todos] of [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
||||||
lines.push(`### ${formatUtcDay(new Date(`${key}T00:00:00Z`), true)}`);
|
|
||||||
for (const todo of todos) {
|
|
||||||
lines.push(`- ${todo.title} (group: ${todo.group}; category: ${todo.category})${formatDetails(todo.details)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.scheduled.length > 0) {
|
|
||||||
lines.push("Scheduled to-dos marked done in this range:");
|
|
||||||
for (const s of data.scheduled) {
|
|
||||||
lines.push(
|
|
||||||
`- ${s.title} (occurrence ${formatUtcDay(s.occurrenceDate, false)}${s.recurring ? ", recurring" : ""}; marked done ${formatUtcDay(s.completedAt, false)})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatByCategory(data: BoardSummaryData): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
for (const category of data.categories) {
|
|
||||||
const withTodos = category.groups.filter((g) => g.todos.length > 0);
|
|
||||||
if (withTodos.length === 0) continue;
|
|
||||||
lines.push(`## Category: "${category.name}"`);
|
|
||||||
for (const group of withTodos) {
|
|
||||||
lines.push(`### Group: "${group.title}"${group.archived ? " [archived]" : ""}`);
|
|
||||||
for (const todo of group.todos) {
|
|
||||||
lines.push(`- ${todo.title} (completed ${formatUtcDay(todo.completedAt, false)})${formatDetails(todo.details)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.scheduled.length > 0) {
|
|
||||||
lines.push("## Scheduled to-dos");
|
|
||||||
for (const s of data.scheduled) {
|
|
||||||
lines.push(
|
|
||||||
`- ${s.title} (occurrence ${formatUtcDay(s.occurrenceDate, false)}${s.recurring ? ", recurring" : ""}; marked done ${formatUtcDay(s.completedAt, false)})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AI summary of everything the user completed on one board (Home or a
|
|
||||||
* Project) inside a date range, organized the way they asked (by day or
|
|
||||||
* by category/group). "Completed" covers both kinds of to-do the app has:
|
|
||||||
* board to-dos whose `completedAt` falls in the range, and scheduled
|
|
||||||
* to-do occurrences marked done in the range. Read-only -- nothing here
|
|
||||||
* is ever saved, so there's no ask/finalize JSON contract, just a plain
|
|
||||||
* markdown answer from the model (see lib/ai/chat-completion.ts).
|
|
||||||
*/
|
|
||||||
export async function summarizeBoard(
|
|
||||||
projectId: string | null,
|
|
||||||
request: SummaryRequest
|
|
||||||
): Promise<BoardSummaryResult> {
|
|
||||||
const userId = await requireUserId();
|
|
||||||
|
|
||||||
const parsed = SummaryRequestSchema.safeParse(request);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { status: "error", error: parsed.error.issues[0]?.message ?? "Invalid request." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = resolveRange(parsed.data, new Date());
|
|
||||||
|
|
||||||
// Scoped to exactly this board: Home means the user's personal
|
|
||||||
// categories; a Project means that project's categories, and only if
|
|
||||||
// this user owns it.
|
|
||||||
const scopeWhere = projectId
|
|
||||||
? { projectId, project: { ownerId: userId } }
|
|
||||||
: { userId, projectId: null };
|
|
||||||
|
|
||||||
const [categories, scheduledCompletions] = await Promise.all([
|
|
||||||
prisma.category.findMany({
|
|
||||||
where: scopeWhere,
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
// Archived groups are deliberately *not* filtered out (unlike
|
|
||||||
// lib/board.ts): this is a history question, and work completed
|
|
||||||
// before a group was archived should still count.
|
|
||||||
include: {
|
|
||||||
groups: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
include: {
|
|
||||||
todos: {
|
|
||||||
where: { completed: true, completedAt: { gte: range.from, lt: range.to } },
|
|
||||||
orderBy: { completedAt: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.scheduledTodoCompletion.findMany({
|
|
||||||
where: {
|
|
||||||
completedAt: { gte: range.from, lt: range.to },
|
|
||||||
scheduledTodo: scopeWhere,
|
|
||||||
},
|
|
||||||
include: { scheduledTodo: { select: { title: true, rrule: true } } },
|
|
||||||
orderBy: { completedAt: "asc" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const data: BoardSummaryData = {
|
|
||||||
categories: categories
|
|
||||||
.map((category) => ({
|
|
||||||
name: category.name,
|
|
||||||
groups: category.groups.map((group) => ({
|
|
||||||
title: group.title,
|
|
||||||
archived: group.archivedAt !== null,
|
|
||||||
todos: group.todos.map((todo) => ({
|
|
||||||
title: todo.title,
|
|
||||||
details: todo.details,
|
|
||||||
completedAt: todo.completedAt as Date,
|
|
||||||
groupTitle: group.title,
|
|
||||||
categoryName: category.name,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
.filter((category) => category.groups.some((group) => group.todos.length > 0)),
|
|
||||||
scheduled: scheduledCompletions.map((c) => ({
|
|
||||||
title: c.scheduledTodo.title,
|
|
||||||
occurrenceDate: c.occurrenceDate,
|
|
||||||
completedAt: c.completedAt,
|
|
||||||
recurring: c.scheduledTodo.rrule !== null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const count =
|
|
||||||
data.categories.reduce((n, c) => n + c.groups.reduce((m, g) => m + g.todos.length, 0), 0) +
|
|
||||||
data.scheduled.length;
|
|
||||||
|
|
||||||
// Skip the AI call entirely when there's nothing to summarize -- a
|
|
||||||
// clear, instant answer beats a model inventing one.
|
|
||||||
if (count === 0) return { status: "empty", rangeLabel: range.label };
|
|
||||||
|
|
||||||
let dataText = parsed.data.organizeBy === "byDate" ? formatByDate(data) : formatByCategory(data);
|
|
||||||
if (dataText.length > TOTAL_CHAR_CAP) {
|
|
||||||
dataText = `${dataText.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional items omitted for length ...]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizeInstruction =
|
|
||||||
parsed.data.organizeBy === "byDate"
|
|
||||||
? 'organize it by day -- keep one section per day, in the same order the data is grouped, with each day as a "## " heading'
|
|
||||||
: 'organize it by category and group -- one "## " section per category and, inside it, a "### " sub-heading (or bold lead-in) per group';
|
|
||||||
|
|
||||||
const systemPrompt = [
|
|
||||||
"You are summarizing completed work from a to-do organizer app, for the person who owns the data.",
|
|
||||||
"",
|
|
||||||
`Date range being summarized: ${range.label}`,
|
|
||||||
"",
|
|
||||||
"Everything completed in that range is listed below, already grouped the way the owner wants the summary organized:",
|
|
||||||
"",
|
|
||||||
dataText,
|
|
||||||
"",
|
|
||||||
"Write a concise, warm markdown summary of what they got done.",
|
|
||||||
"- Output ONLY the markdown summary itself -- no preamble, no closing remarks, no code fences around it.",
|
|
||||||
`- ${organizeInstruction}. If scheduled to-dos are present, keep them in their own final section.`,
|
|
||||||
"- Open with one or two sentences recapping the overall amount of work in the range.",
|
|
||||||
"- Render completed items as short bullet lines; merge exact duplicates into a single line.",
|
|
||||||
"- Keep the whole summary tight (roughly 30 lines or fewer).",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const result = await callChatCompletion(
|
|
||||||
[
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: "Summarize it now." },
|
|
||||||
],
|
|
||||||
// This is the app's biggest prompt (a whole range of completed work),
|
|
||||||
// and the configured provider may be a slow reasoning model -- give it
|
|
||||||
// room to finish rather than the 30s default.
|
|
||||||
{ temperature: 0.3, timeoutMs: 120_000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("error" in result) return { status: "error", error: result.error };
|
|
||||||
return { status: "ok", content: result.content, rangeLabel: range.label, count };
|
|
||||||
}
|
|
||||||
|
|
@ -33,7 +33,7 @@ function extractMessageContent(body: unknown): string | null {
|
||||||
*/
|
*/
|
||||||
export async function callChatCompletion(
|
export async function callChatCompletion(
|
||||||
messages: ChatCompletionMessage[],
|
messages: ChatCompletionMessage[],
|
||||||
opts?: { temperature?: number; timeoutMs?: number }
|
opts?: { temperature?: number }
|
||||||
): Promise<ChatCompletionResult> {
|
): Promise<ChatCompletionResult> {
|
||||||
const [{ apiUrl, apiKey }, settings] = await Promise.all([
|
const [{ apiUrl, apiKey }, settings] = await Promise.all([
|
||||||
getAiCredentials(),
|
getAiCredentials(),
|
||||||
|
|
@ -63,7 +63,7 @@ export async function callChatCompletion(
|
||||||
messages,
|
messages,
|
||||||
temperature: opts?.temperature ?? 0.4,
|
temperature: opts?.temperature ?? 0.4,
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(opts?.timeoutMs ?? 30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
125
lib/colors.ts
|
|
@ -48,98 +48,6 @@ export const GROUP_COLORS: GroupColor[] = [
|
||||||
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6", soft: "#fadfee", softDark: "#37202e" },
|
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6", soft: "#fadfee", softDark: "#37202e" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Cyberpunk neon palette ---
|
|
||||||
// A secondary palette used ONLY when the "cyberpunk" theme is active.
|
|
||||||
// These are high-saturation neon values (magenta, cyan, acid green, etc.)
|
|
||||||
// that give group cards that classic "neon in the night" look. The
|
|
||||||
// softDark fills are shifted well down in lightness so they read as a
|
|
||||||
// subtle glow against the near-black surfaces instead of washed-out pastels.
|
|
||||||
|
|
||||||
const CYBERPUNK_PALETTE: Record<string, GroupColor> = {
|
|
||||||
coral: { key: "coral", label: "Neon Pink", light: "#ff2a8f", dark: "#ff2a8f", soft: "#fadfee", softDark: "#3d1433" },
|
|
||||||
amber: { key: "amber", label: "Acid Lime", light: "#a8ff00", dark: "#a8ff00", soft: "#eeffe0", softDark: "#2d3a12" },
|
|
||||||
lime: { key: "lime", label: "Toxic Green", light: "#39ff14", dark: "#39ff14", soft: "#e0fde0", softDark: "#163d14" },
|
|
||||||
teal: { key: "teal", label: "Electric Cyan",light: "#00fff0", dark: "#00fff0", soft: "#dffffa", softDark: "#0f3d3a" },
|
|
||||||
sky: { key: "sky", label: "Voltage Blue",light: "#00aaff", dark: "#00aaff", soft: "#e0f0ff", softDark: "#12263d" },
|
|
||||||
indigo: { key: "indigo", label: "Neon Purple", light: "#bf40ff", dark: "#bf40ff", soft: "#f0e0ff", softDark: "#2e1640" },
|
|
||||||
violet: { key: "violet", label: "Neon Magenta",light: "#ff2aff", dark: "#ff2aff", soft: "#ffe0f5", softDark: "#3d1436" },
|
|
||||||
pink: { key: "pink", label: "Deep Violet", light: "#8a2be2", dark: "#8a2be2", soft: "#e8d6ff", softDark: "#261236" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Blueprint drafting-ink palette ---
|
|
||||||
// Used ONLY when the "blueprint" theme is active. The cards are white
|
|
||||||
// drafting paper on a cobalt board, so the strokes read as technical pen
|
|
||||||
// inks -- cobalt and ultramarine for the primary lines, with the classic
|
|
||||||
// annotation inks (proof red, sepia, green, cerulean) for the rest. Calm,
|
|
||||||
// even-saturation inks, not neon: this is a drafting table, not a nightclub.
|
|
||||||
|
|
||||||
const BLUEPRINT_PALETTE: Record<string, GroupColor> = {
|
|
||||||
coral: { key: "coral", label: "Proof Red", light: "#b83a4a", dark: "#b83a4a", soft: "#fbe5e7", softDark: "#3a2320" },
|
|
||||||
amber: { key: "amber", label: "Sepia Ink", light: "#a06325", dark: "#a06325", soft: "#f5ecda", softDark: "#3a301c" },
|
|
||||||
lime: { key: "lime", label: "Draft Green", light: "#3d7a46", dark: "#3d7a46", soft: "#e3efe1", softDark: "#25331d" },
|
|
||||||
teal: { key: "teal", label: "Cerulean Ink", light: "#1d6f74", dark: "#1d6f74", soft: "#ddefee", softDark: "#16332f" },
|
|
||||||
sky: { key: "sky", label: "Cobalt Ink", light: "#2456b4", dark: "#2456b4", soft: "#e0e9f8", softDark: "#1a2b40" },
|
|
||||||
indigo: { key: "indigo", label: "Ultramarine", light: "#43489f", dark: "#43489f", soft: "#e4e5f5", softDark: "#23233c" },
|
|
||||||
violet: { key: "violet", label: "Violet Ink", light: "#6d43a8", dark: "#6d43a8", soft: "#ece2f7", softDark: "#2e2136" },
|
|
||||||
pink: { key: "pink", label: "Magenta Ink", light: "#b03586", dark: "#b03586", soft: "#f7e1ef", softDark: "#37202e" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Vaporwave retro palette ---
|
|
||||||
// Used ONLY when the "vaporwave" theme is active. Pastel synthwave sky with
|
|
||||||
// a glossy 90s-desktop gloss, so the strokes are soft retro-neon rather than
|
|
||||||
// full cyberpunk brightness: Miami pink, sunset gold, CRT mint, electric
|
|
||||||
// aqua, chrome blue, laser purple. Saturated enough to read on the pale
|
|
||||||
// card, muted enough to sit in the pastel sky.
|
|
||||||
|
|
||||||
const VAPORWAVE_PALETTE: Record<string, GroupColor> = {
|
|
||||||
coral: { key: "coral", label: "Miami Pink", light: "#e64f9e", dark: "#e64f9e", soft: "#fbe0ef", softDark: "#3d1433" },
|
|
||||||
amber: { key: "amber", label: "Sunset Gold", light: "#e08a1f", dark: "#e08a1f", soft: "#fbeed6", softDark: "#3a301c" },
|
|
||||||
lime: { key: "lime", label: "CRT Mint", light: "#2fb584", dark: "#2fb584", soft: "#dcf3e9", softDark: "#163d14" },
|
|
||||||
teal: { key: "teal", label: "Electric Aqua", light: "#14a9c4", dark: "#14a9c4", soft: "#daf0f5", softDark: "#0f3d3a" },
|
|
||||||
sky: { key: "sky", label: "Chrome Blue", light: "#3f6fe0", dark: "#3f6fe0", soft: "#e0e8fb", softDark: "#1a2b40" },
|
|
||||||
indigo: { key: "indigo", label: "Laser Purple", light: "#7c4fe0", dark: "#7c4fe0", soft: "#e8e1fa", softDark: "#23233c" },
|
|
||||||
violet: { key: "violet", label: "Vapor Magenta", light: "#b455d4", dark: "#b455d4", soft: "#f3e2fa", softDark: "#2e2136" },
|
|
||||||
pink: { key: "pink", label: "Pastel Orchid", light: "#cb6ca8", dark: "#cb6ca8", soft: "#f9e4f1", softDark: "#37202e" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Notebook stationery palette ---
|
|
||||||
// Used ONLY when the "notebook" theme is active. The cards are index cards
|
|
||||||
// on a warm desk, so the strokes read like the pens and markers in the
|
|
||||||
// desk drawer: blue ballpoint, red pencil, green pen, teal marker, purple
|
|
||||||
// pen, rose highlighter. Muted, papery, hand-inked -- the opposite of
|
|
||||||
// glowing; these are inks that dry on paper.
|
|
||||||
|
|
||||||
const NOTEBOOK_PALETTE: Record<string, GroupColor> = {
|
|
||||||
coral: { key: "coral", label: "Red Pencil", light: "#c04a45", dark: "#c04a45", soft: "#fae6e2", softDark: "#3a2320" },
|
|
||||||
amber: { key: "amber", label: "Orange Marker", light: "#d9822b", dark: "#d9822b", soft: "#f9ecd7", softDark: "#3a301c" },
|
|
||||||
lime: { key: "lime", label: "Green Pen", light: "#46905b", dark: "#46905b", soft: "#e3f0e6", softDark: "#25331d" },
|
|
||||||
teal: { key: "teal", label: "Teal Marker", light: "#2a8f8b", dark: "#2a8f8b", soft: "#dcefec", softDark: "#16332f" },
|
|
||||||
sky: { key: "sky", label: "Blue Ballpoint", light: "#2f5cb8", dark: "#2f5cb8", soft: "#e2e9fa", softDark: "#1a2b40" },
|
|
||||||
indigo: { key: "indigo", label: "Purple Pen", light: "#5b4bb0", dark: "#5b4bb0", soft: "#e6e3f6", softDark: "#23233c" },
|
|
||||||
violet: { key: "violet", label: "Violet Ink", light: "#8552b8", dark: "#8552b8", soft: "#ede4f7", softDark: "#2e2136" },
|
|
||||||
pink: { key: "pink", label: "Rose Highlighter", light: "#c25a93", dark: "#c25a93", soft: "#f8e5f0", softDark: "#37202e" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Starfield starlight palette ---
|
|
||||||
// Used ONLY when the "starfield" theme is active. The cards float in a
|
|
||||||
// near-black indigo void, so the strokes read as starlight: bright but
|
|
||||||
// soft, like distant stars and console glows -- star gold, aurora, ion
|
|
||||||
// cyan, nebula violet. Deliberately gentler than cyberpunk's neon; these
|
|
||||||
// are lights seen across a galaxy, not tubes in a night city. softDark
|
|
||||||
// fills are low-light tints so they read as faint nebulae against the
|
|
||||||
// void instead of washed-out pastels.
|
|
||||||
|
|
||||||
const STARFIELD_PALETTE: Record<string, GroupColor> = {
|
|
||||||
coral: { key: "coral", label: "Red Giant", light: "#ff8592", dark: "#ff8592", soft: "#fadfee", softDark: "#3a2026" },
|
|
||||||
amber: { key: "amber", label: "Star Gold", light: "#ffc86b", dark: "#ffc86b", soft: "#eeffe0", softDark: "#3a3018" },
|
|
||||||
lime: { key: "lime", label: "Aurora", light: "#5fd6a0", dark: "#5fd6a0", soft: "#e0fde0", softDark: "#173a2b" },
|
|
||||||
teal: { key: "teal", label: "Ion Cyan", light: "#55c8f0", dark: "#55c8f0", soft: "#dffffa", softDark: "#14303d" },
|
|
||||||
sky: { key: "sky", label: "Polar Blue", light: "#7b9df5", dark: "#7b9df5", soft: "#e0f0ff", softDark: "#1b2542" },
|
|
||||||
indigo: { key: "indigo", label: "Nebula Violet", light: "#a48bfa", dark: "#a48bfa", soft: "#f0e0ff", softDark: "#271f42" },
|
|
||||||
violet: { key: "violet", label: "Nebula Pink", light: "#d18ff0", dark: "#d18ff0", soft: "#ffe0f5", softDark: "#33203a" },
|
|
||||||
pink: { key: "pink", label: "Rose Star", light: "#f591bb", dark: "#f591bb", soft: "#e8d6ff", softDark: "#3a2230" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries(
|
const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries(
|
||||||
GROUP_COLORS.map((c) => [c.key, c])
|
GROUP_COLORS.map((c) => [c.key, c])
|
||||||
);
|
);
|
||||||
|
|
@ -150,33 +58,6 @@ export function getGroupColor(key: string): GroupColor {
|
||||||
return GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0];
|
return GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the cyberpunk-neon variant of a group color. Used ONLY when the
|
|
||||||
* "cyberpunk" theme is active so groups read as neon tubes in the night.
|
|
||||||
*/
|
|
||||||
export function getCyberpunkGroupColor(key: string): GroupColor {
|
|
||||||
return CYBERPUNK_PALETTE[key] ?? CYBERPUNK_PALETTE["coral"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every theme-specific palette in one place, keyed by theme name (the
|
|
||||||
// values in components/theme/theme-provider's THEMES). Themes without an
|
|
||||||
// entry keep the base GROUP_COLORS above.
|
|
||||||
const THEME_PALETTES: Partial<Record<string, Record<string, GroupColor>>> = {
|
|
||||||
cyberpunk: CYBERPUNK_PALETTE,
|
|
||||||
blueprint: BLUEPRINT_PALETTE,
|
|
||||||
vaporwave: VAPORWAVE_PALETTE,
|
|
||||||
notebook: NOTEBOOK_PALETTE,
|
|
||||||
starfield: STARFIELD_PALETTE,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The group color for a given theme: that theme's palette if it has one
|
|
||||||
* (cyberpunk, blueprint, vaporwave, notebook, starfield), otherwise the
|
|
||||||
* base GROUP_COLORS entry. Used by the themed group cards so each
|
|
||||||
* character's board carries the theme's own ink.
|
|
||||||
*/
|
|
||||||
export function getThemedGroupColor(theme: string, key: string): GroupColor {
|
|
||||||
return THEME_PALETTES[theme]?.[key] ?? GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Complementary accent (for the to-do progress pie on a Group card) ---
|
// --- Complementary accent (for the to-do progress pie on a Group card) ---
|
||||||
//
|
//
|
||||||
// Derived at runtime by rotating a base color's hue 180° and pulling the
|
// Derived at runtime by rotating a base color's hue 180° and pulling the
|
||||||
|
|
@ -283,11 +164,7 @@ export function getBrighterColor(hex: string, variant: "light" | "dark"): string
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const [hue, saturation, lightness] = rgbToHsl(...hexToRgb(hex));
|
const [hue, saturation, lightness] = rgbToHsl(...hexToRgb(hex));
|
||||||
// Light surfaces: the to-do ink runs DEEPER than the group's stroke (not
|
const delta = variant === "dark" ? 8 : -8;
|
||||||
// lighter) so it clears WCAG AA (>=4.5:1) against the near-white pastel
|
|
||||||
// card fills -- a flat -8 only reached ~3.3:1 for the warm hues. Dark
|
|
||||||
// surfaces do the opposite: brighter ink over the dark tint.
|
|
||||||
const delta = variant === "dark" ? 8 : -16;
|
|
||||||
const result = hslToHex(
|
const result = hslToHex(
|
||||||
hue,
|
hue,
|
||||||
saturation,
|
saturation,
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@ export async function getScheduledTodoBoard(
|
||||||
occurrenceDate: key,
|
occurrenceDate: key,
|
||||||
completed: completedDates.has(key),
|
completed: completedDates.has(key),
|
||||||
isRecurring: !!todo.rrule,
|
isRecurring: !!todo.rrule,
|
||||||
timeDue: todo.timeDue,
|
|
||||||
remindMinutesBefore: todo.remindMinutesBefore,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (key < todayKey) {
|
if (key < todayKey) {
|
||||||
|
|
|
||||||
133
lib/themes.ts
|
|
@ -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){}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
// Plain "HH:MM" (24-hour) wall-clock time-of-day helpers for a
|
|
||||||
// ScheduledTodo's optional time due. Deliberately just a string, not a
|
|
||||||
// Date -- there's no timezone or calendar date attached until it's paired
|
|
||||||
// with one occurrence's date key (see isTimeDuePast). This is local wall
|
|
||||||
// time, not UTC -- unlike lib/dates.ts, which is calendar-date math the
|
|
||||||
// server and client must agree on byte-for-byte.
|
|
||||||
|
|
||||||
const TIME_DUE_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
||||||
|
|
||||||
export function isValidTimeDue(value: string): boolean {
|
|
||||||
return TIME_DUE_REGEX.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "17:05" -> "5:05 PM", for display next to a scheduled occurrence. */
|
|
||||||
export function formatTimeDue(hhmm: string): string {
|
|
||||||
const [hours, minutes] = hhmm.split(":").map(Number);
|
|
||||||
const period = hours < 12 ? "AM" : "PM";
|
|
||||||
const hour12 = hours % 12 === 0 ? 12 : hours % 12;
|
|
||||||
return `${hour12}:${String(minutes).padStart(2, "0")} ${period}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True once the viewer's local wall clock has passed `hhmm` on the local
|
|
||||||
* calendar day named by `dateKey` ("YYYY-MM-DD") -- used to promote
|
|
||||||
* today's own occurrence into Overdue as soon as its time due passes,
|
|
||||||
* without waiting for the date to roll over. Always evaluated client-side:
|
|
||||||
* the server doesn't know the viewer's timezone, so it only buckets by
|
|
||||||
* date (see lib/scheduled-todos.ts) and leaves same-day time comparisons
|
|
||||||
* to the browser's own clock. `now` defaults to the real clock but is
|
|
||||||
* injectable for tests.
|
|
||||||
*/
|
|
||||||
export function isTimeDuePast(dateKey: string, hhmm: string, now: Date = new Date()): boolean {
|
|
||||||
const due = new Date(`${dateKey}T${hhmm}:00`); // no "Z" -- local time
|
|
||||||
return now.getTime() > due.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "Remind me" dropdown's choices -- lead time before `timeDue` to fire
|
|
||||||
// a notification (see use-scheduled-notifications.ts). `minutesBefore:
|
|
||||||
// null` is "Don't remind me" -- a to-do can have a time due with no
|
|
||||||
// reminder at all, distinct from "When due" (0 minutes early).
|
|
||||||
export const REMIND_OPTIONS: { value: string; label: string; minutesBefore: number | null }[] = [
|
|
||||||
{ value: "none", label: "Don't remind me", minutesBefore: null },
|
|
||||||
{ value: "0", label: "When due", minutesBefore: 0 },
|
|
||||||
{ value: "5", label: "5 minutes before", minutesBefore: 5 },
|
|
||||||
{ value: "10", label: "10 minutes before", minutesBefore: 10 },
|
|
||||||
{ value: "15", label: "15 minutes before", minutesBefore: 15 },
|
|
||||||
{ value: "30", label: "30 minutes before", minutesBefore: 30 },
|
|
||||||
{ value: "60", label: "1 hour before", minutesBefore: 60 },
|
|
||||||
{ value: "120", label: "2 hours before", minutesBefore: 120 },
|
|
||||||
{ value: "1440", label: "1 day before", minutesBefore: 1440 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** True once the viewer's local wall clock has reached `minutesBefore`
|
|
||||||
* minutes ahead of `hhmm` on `dateKey` -- i.e. it's time to fire the
|
|
||||||
* reminder. Distinct from isTimeDuePast: a reminder can (and typically
|
|
||||||
* does) fire before the to-do is actually due. */
|
|
||||||
export function isReminderDue(
|
|
||||||
dateKey: string,
|
|
||||||
hhmm: string,
|
|
||||||
minutesBefore: number,
|
|
||||||
now: Date = new Date()
|
|
||||||
): boolean {
|
|
||||||
const due = new Date(`${dateKey}T${hhmm}:00`); // no "Z" -- local time
|
|
||||||
return now.getTime() >= due.getTime() - minutesBefore * 60_000;
|
|
||||||
}
|
|
||||||
|
|
@ -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>;
|
|
||||||
|
|
@ -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>;
|
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,6 @@ export const ScheduledTodoTitleSchema = z
|
||||||
|
|
||||||
export const ScheduledTodoDetailsSchema = z.string().max(5_000).optional();
|
export const ScheduledTodoDetailsSchema = z.string().max(5_000).optional();
|
||||||
|
|
||||||
// "HH:MM", 24-hour -- exactly what an <input type="time"> gives back, so
|
|
||||||
// the client never needs to reformat before sending it.
|
|
||||||
export const ScheduledTodoTimeDueSchema = z
|
|
||||||
.string()
|
|
||||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time");
|
|
||||||
|
|
||||||
// Minutes of lead time before timeDue -- 0 ("When due") up to a week.
|
|
||||||
export const ScheduledTodoRemindMinutesBeforeSchema = z.number().int().min(0).max(10_080);
|
|
||||||
|
|
||||||
const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
|
const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
|
||||||
|
|
||||||
const RecurrenceEndSchema = z.discriminatedUnion("type", [
|
const RecurrenceEndSchema = z.discriminatedUnion("type", [
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// "YYYY-MM-DD" -- exactly what an <input type="date"> gives back, so the
|
|
||||||
// client never needs to reformat before sending it. Lexicographic string
|
|
||||||
// comparison is a valid chronological comparison for this shape.
|
|
||||||
const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
|
|
||||||
|
|
||||||
export const SummaryDateRangeSchema = z.enum([
|
|
||||||
"today",
|
|
||||||
"thisWeek",
|
|
||||||
"thisMonth",
|
|
||||||
"lastMonth",
|
|
||||||
"custom",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const SummaryOrganizeBySchema = z.enum(["byDate", "byCategory"]);
|
|
||||||
|
|
||||||
export const SummaryRequestSchema = z
|
|
||||||
.object({
|
|
||||||
dateRange: SummaryDateRangeSchema,
|
|
||||||
// Only read when dateRange is "custom" -- enforced conditionally below.
|
|
||||||
customStart: DateStringSchema.optional(),
|
|
||||||
customEnd: DateStringSchema.optional(),
|
|
||||||
organizeBy: SummaryOrganizeBySchema,
|
|
||||||
})
|
|
||||||
.superRefine((value, ctx) => {
|
|
||||||
if (value.dateRange !== "custom") return;
|
|
||||||
if (!value.customStart) {
|
|
||||||
ctx.addIssue({ code: "custom", path: ["customStart"], message: "A start date is required." });
|
|
||||||
}
|
|
||||||
if (!value.customEnd) {
|
|
||||||
ctx.addIssue({ code: "custom", path: ["customEnd"], message: "An end date is required." });
|
|
||||||
}
|
|
||||||
if (value.customStart && value.customEnd && value.customStart > value.customEnd) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["customStart"],
|
|
||||||
message: "The start date must be on or before the end date.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SummaryRequest = z.infer<typeof SummaryRequestSchema>;
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ScheduledTodo" ADD COLUMN "timeDue" VARCHAR(5);
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ScheduledTodo" ADD COLUMN "remindMinutesBefore" INTEGER;
|
|
||||||
|
|
||||||
-- Backfill: to-dos that already had a time due before this column existed
|
|
||||||
-- were, in effect, always reminded "when due" -- preserve that behavior
|
|
||||||
-- for them rather than silently turning reminders off.
|
|
||||||
UPDATE "ScheduledTodo" SET "remindMinutesBefore" = 0 WHERE "timeDue" IS NOT NULL;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Project" ADD COLUMN "theme" TEXT;
|
|
||||||
|
|
@ -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+';
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "theme" TEXT;
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -314,20 +193,6 @@ model ScheduledTodo {
|
||||||
// via the `rrule` package -- never trust a client-supplied string, it's
|
// via the `rrule` package -- never trust a client-supplied string, it's
|
||||||
// always rebuilt server-side from validated parts. Null = one-time.
|
// always rebuilt server-side from validated parts. Null = one-time.
|
||||||
rrule String?
|
rrule String?
|
||||||
// Optional "HH:MM" (24-hour) wall-clock time this to-do is due by --
|
|
||||||
// the same value applies to every occurrence of a recurring to-do. Null
|
|
||||||
// = due sometime that day, no specific time. It's local wall time with
|
|
||||||
// no timezone of its own; "is this occurrence's time due already past"
|
|
||||||
// is only ever evaluated client-side, against the viewer's own clock
|
|
||||||
// (see lib/time-of-day.ts) -- the server just stores and passes it through.
|
|
||||||
timeDue String? @db.VarChar(5)
|
|
||||||
// Minutes before `timeDue` to fire the "Remind me" notification -- 0 =
|
|
||||||
// "When due", a larger number = that many minutes earlier. Null = no
|
|
||||||
// reminder even though a time due is set (the user hasn't picked one, or
|
|
||||||
// explicitly chose "Don't remind me"). Always null whenever timeDue
|
|
||||||
// itself is null -- there's nothing to count down from otherwise
|
|
||||||
// (enforced in lib/actions/scheduled-todos.ts, not by a DB constraint).
|
|
||||||
remindMinutesBefore Int?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,6 @@ export interface ScheduledOccurrenceDTO {
|
||||||
occurrenceDate: string;
|
occurrenceDate: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
isRecurring: boolean;
|
isRecurring: boolean;
|
||||||
// "HH:MM" (24-hour), or null if this to-do has no specific time due.
|
|
||||||
// See lib/time-of-day.ts -- comparing it to "now" is a client-only concern.
|
|
||||||
timeDue: string | null;
|
|
||||||
// Minutes of lead time before timeDue to notify at, null = no reminder.
|
|
||||||
// Only meaningful when timeDue is set. See lib/time-of-day.ts's
|
|
||||||
// REMIND_OPTIONS for the selectable values.
|
|
||||||
remindMinutesBefore: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduledDayDTO {
|
export interface ScheduledDayDTO {
|
||||||
|
|
@ -45,8 +38,4 @@ export interface ScheduledTodoEditDTO {
|
||||||
// "YYYY-MM-DD"
|
// "YYYY-MM-DD"
|
||||||
startDate: string;
|
startDate: string;
|
||||||
recurrence: RecurrenceInput | null;
|
recurrence: RecurrenceInput | null;
|
||||||
// "HH:MM" (24-hour), or null if this to-do has no specific time due.
|
|
||||||
timeDue: string | null;
|
|
||||||
// Minutes of lead time before timeDue to notify at, null = no reminder.
|
|
||||||
remindMinutesBefore: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||