Compare commits
2 Commits
5ba0edd856
...
ed1d2d956a
| Author | SHA1 | Date | |
|---|---|---|---|
| ed1d2d956a | |||
| dc6722cd3f |
65
README.md
65
README.md
@ -4,9 +4,21 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
|
|||||||
|
|
||||||
## Current Product Behavior
|
## Current Product Behavior
|
||||||
|
|
||||||
|
### Feb 20, 2026 updates
|
||||||
|
|
||||||
|
- Added task attachments with SQLite persistence.
|
||||||
|
- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link.
|
||||||
|
- Replaced flat notes/comments with threaded comments.
|
||||||
|
- Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
|
||||||
|
- Updated UI wording from "notes" to "comments".
|
||||||
|
- Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing.
|
||||||
|
- Added lightweight collaborator identity tracking for task/comment authorship.
|
||||||
|
|
||||||
### Data model and status rules
|
### Data model and status rules
|
||||||
|
|
||||||
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
||||||
|
- Tasks support attachments (`attachments: TaskAttachment[]`).
|
||||||
|
- Tasks now track `createdById`, `createdByName`, `updatedById`, and `updatedByName`.
|
||||||
- There is no active `backlog` status in workflow logic.
|
- There is no active `backlog` status in workflow logic.
|
||||||
- A task is considered in Backlog when `sprintId` is empty.
|
- A task is considered in Backlog when `sprintId` is empty.
|
||||||
- Current status values:
|
- Current status values:
|
||||||
@ -21,18 +33,68 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
|
|||||||
- `done`
|
- `done`
|
||||||
- New tasks default to `status: open`.
|
- New tasks default to `status: open`.
|
||||||
|
|
||||||
|
### Authentication and sessions
|
||||||
|
|
||||||
|
- Added account-based authentication with:
|
||||||
|
- `POST /api/auth/register`
|
||||||
|
- `POST /api/auth/login`
|
||||||
|
- `POST /api/auth/logout`
|
||||||
|
- `GET /api/auth/session`
|
||||||
|
- Added a dedicated login/register screen at `/login`.
|
||||||
|
- Added account settings at `/settings` for authenticated users.
|
||||||
|
- Main board (`/`) and task detail pages (`/tasks/{taskId}`) require an authenticated session.
|
||||||
|
- Main board and task detail headers include quick access to Settings and Logout.
|
||||||
|
- API routes now enforce auth on task read/write/delete (`/api/tasks` returns `401` when unauthenticated).
|
||||||
|
- Added `PATCH /api/auth/account` to update name/email/password for the current user.
|
||||||
|
- Session cookie: `gantt_session` (HTTP-only, same-site lax, secure in production).
|
||||||
|
- Added `Remember me` in auth forms:
|
||||||
|
- Checked: persistent 30-day cookie/session.
|
||||||
|
- Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry.
|
||||||
|
- Task/comment authorship now uses authenticated user identity.
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
- Project selection UI for tasks was removed in favor of labels.
|
- Project selection UI for tasks was removed in favor of labels.
|
||||||
- You can add/remove labels inline in:
|
- You can add/remove labels inline in:
|
||||||
- New Task modal
|
- New Task modal
|
||||||
- Task Detail modal
|
- Task Detail page
|
||||||
- Label entry supports:
|
- Label entry supports:
|
||||||
- Enter/comma to add
|
- Enter/comma to add
|
||||||
- Existing-label suggestions
|
- Existing-label suggestions
|
||||||
- Quick-add chips
|
- Quick-add chips
|
||||||
- Case-insensitive de-duplication
|
- Case-insensitive de-duplication
|
||||||
|
|
||||||
|
### Task detail pages
|
||||||
|
|
||||||
|
- Clicking a task from Kanban or Backlog opens a dedicated route:
|
||||||
|
- `/tasks/{taskId}`
|
||||||
|
- Task detail is no longer popup-only behavior.
|
||||||
|
- Each task now has a shareable deep link.
|
||||||
|
|
||||||
|
### Attachments
|
||||||
|
|
||||||
|
- Task detail page supports adding multiple attachments per task.
|
||||||
|
- Attachments are stored with each task in SQLite and survive refresh/restart.
|
||||||
|
- Attachment UI supports:
|
||||||
|
- Upload multiple files
|
||||||
|
- Open/view file
|
||||||
|
- Markdown (`.md`) preview rendering in browser tab
|
||||||
|
- Text/code preview for common file types including iOS project/code files (`.swift`, `.plist`, `.pbxproj`, `.storyboard`, `.xib`, `.xcconfig`, `.entitlements`)
|
||||||
|
- Syntax-highlighted source rendering (highlight.js) with copy-source action
|
||||||
|
- CSV table preview and sandboxed HTML preview with source view
|
||||||
|
- Download file
|
||||||
|
- Remove attachment
|
||||||
|
|
||||||
|
### Threaded comments
|
||||||
|
|
||||||
|
- Comments are now threaded, not flat.
|
||||||
|
- You can:
|
||||||
|
- Add top-level comments
|
||||||
|
- Reply to any comment
|
||||||
|
- Reply to replies recursively (unlimited depth)
|
||||||
|
- Delete any comment or reply from the thread
|
||||||
|
- Thread data is persisted in SQLite through the existing `/api/tasks` sync flow.
|
||||||
|
|
||||||
### Backlog drag and drop
|
### Backlog drag and drop
|
||||||
|
|
||||||
Backlog view supports moving tasks between:
|
Backlog view supports moving tasks between:
|
||||||
@ -85,6 +147,7 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`.
|
Open `http://localhost:3000`.
|
||||||
|
Task URLs follow `http://localhost:3000/tasks/{taskId}`.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
54
src/app/api/auth/account/route.ts
Normal file
54
src/app/api/auth/account/route.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthenticatedUser, updateUserAccount } from "@/lib/server/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getAuthenticatedUser();
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextName = typeof body.name === "string" ? body.name : undefined;
|
||||||
|
const nextEmail = typeof body.email === "string" ? body.email : undefined;
|
||||||
|
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : undefined;
|
||||||
|
const newPassword = typeof body.newPassword === "string" ? body.newPassword : undefined;
|
||||||
|
|
||||||
|
const user = updateUserAccount({
|
||||||
|
userId: sessionUser.id,
|
||||||
|
name: nextName,
|
||||||
|
email: nextEmail,
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, user });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Account update failed";
|
||||||
|
if (message === "User not found") {
|
||||||
|
return NextResponse.json({ error: message }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (message === "Current password is incorrect") {
|
||||||
|
return NextResponse.json({ error: message }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (message.includes("exists")) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.includes("Current password is required")
|
||||||
|
|| message.includes("at least")
|
||||||
|
|| message.includes("Invalid email")
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Account update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/auth/login/route.ts
Normal file
38
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateUser, createUserSession, setSessionCookie } from "@/lib/server/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = (body.email || "").trim();
|
||||||
|
const password = body.password || "";
|
||||||
|
const rememberMe = Boolean(body.rememberMe);
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json({ error: "Email and password are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = authenticateUser({ email, password });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createUserSession(user.id, rememberMe);
|
||||||
|
await setSessionCookie(session.token, rememberMe);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
session: { expiresAt: session.expiresAt, rememberMe },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Login failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/api/auth/logout/route.ts
Normal file
15
src/app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { clearSessionCookie, getSessionTokenFromCookies, revokeSession } from "@/lib/server/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const token = await getSessionTokenFromCookies();
|
||||||
|
if (token) revokeSession(token);
|
||||||
|
await clearSessionCookie();
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Logout failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/auth/register/route.ts
Normal file
38
src/app/api/auth/register/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createUserSession, registerUser, setSessionCookie } from "@/lib/server/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = (body.name || "").trim();
|
||||||
|
const email = (body.email || "").trim();
|
||||||
|
const password = body.password || "";
|
||||||
|
const rememberMe = Boolean(body.rememberMe);
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return NextResponse.json({ error: "Name, email, and password are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = registerUser({ name, email, password });
|
||||||
|
const session = createUserSession(user.id, rememberMe);
|
||||||
|
await setSessionCookie(session.token, rememberMe);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
session: { expiresAt: session.expiresAt, rememberMe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Registration failed";
|
||||||
|
const status = message.includes("exists") ? 409 : 400;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/auth/session/route.ts
Normal file
16
src/app/api/auth/session/route.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ authenticated: true, user });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Session check failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,16 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb";
|
import { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
// GET - fetch all tasks, projects, and sprints
|
// GET - fetch all tasks, projects, and sprints
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
const data = getData();
|
const data = getData();
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -17,6 +22,11 @@ export async function GET() {
|
|||||||
// POST - create or update tasks, projects, or sprints
|
// POST - create or update tasks, projects, or sprints
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { task, tasks, projects, sprints } = body as {
|
const { task, tasks, projects, sprints } = body as {
|
||||||
task?: Task;
|
task?: Task;
|
||||||
@ -33,19 +43,34 @@ export async function POST(request: Request) {
|
|||||||
if (task) {
|
if (task) {
|
||||||
const existingIndex = data.tasks.findIndex((t) => t.id === task.id);
|
const existingIndex = data.tasks.findIndex((t) => t.id === task.id);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
data.tasks[existingIndex] = { ...task, updatedAt: new Date().toISOString() };
|
data.tasks[existingIndex] = {
|
||||||
|
...task,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedById: user.id,
|
||||||
|
updatedByName: user.name,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
data.tasks.push({
|
data.tasks.push({
|
||||||
...task,
|
...task,
|
||||||
id: task.id || Date.now().toString(),
|
id: task.id || Date.now().toString(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdById: task.createdById || user.id,
|
||||||
|
createdByName: task.createdByName || user.name,
|
||||||
|
updatedById: user.id,
|
||||||
|
updatedByName: user.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks && Array.isArray(tasks)) {
|
if (tasks && Array.isArray(tasks)) {
|
||||||
data.tasks = tasks;
|
data.tasks = tasks.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
createdById: entry.createdById || user.id,
|
||||||
|
createdByName: entry.createdByName || user.name,
|
||||||
|
updatedById: entry.updatedById || user.id,
|
||||||
|
updatedByName: entry.updatedByName || user.name,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = saveData(data);
|
const saved = saveData(data);
|
||||||
@ -59,6 +84,11 @@ export async function POST(request: Request) {
|
|||||||
// DELETE - remove a task
|
// DELETE - remove a task
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = (await request.json()) as { id: string };
|
const { id } = (await request.json()) as { id: string };
|
||||||
const data = getData();
|
const data = getData();
|
||||||
data.tasks = data.tasks.filter((t) => t.id !== id);
|
data.tasks = data.tasks.filter((t) => t.id !== id);
|
||||||
@ -69,4 +99,3 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
src/app/login/page.tsx
Normal file
148
src/app/login/page.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [rememberMe, setRememberMe] = useState(true)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||||
|
if (res.ok && isMounted) {
|
||||||
|
router.replace("/")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and stay on login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!email.trim() || !password) {
|
||||||
|
setError("Email and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "register" && !name.trim()) {
|
||||||
|
setError("Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, email, password, rememberMe }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Authentication failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/")
|
||||||
|
} catch {
|
||||||
|
setError("Authentication failed")
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900/70 p-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-white mb-2">OpenClaw Task Hub</h1>
|
||||||
|
<p className="text-sm text-slate-400 mb-6">Sign in to continue.</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-6 bg-slate-800 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("login")}
|
||||||
|
className={`flex-1 px-3 py-2 rounded text-sm ${mode === "login" ? "bg-slate-700 text-white" : "text-slate-400"}`}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("register")}
|
||||||
|
className={`flex-1 px-3 py-2 rounded text-sm ${mode === "register" ? "bg-slate-700 text-white" : "text-slate-400"}`}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mode === "register" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="Your display name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(event) => setRememberMe(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
<Button onClick={submit} className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
src/app/page.tsx
186
src/app/page.tsx
@ -22,7 +22,16 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
|
import {
|
||||||
|
blobFromDataUrl,
|
||||||
|
coerceDataUrlMimeType,
|
||||||
|
inferAttachmentMimeType,
|
||||||
|
isMarkdownAttachment,
|
||||||
|
isTextPreviewAttachment,
|
||||||
|
markdownPreviewObjectUrl,
|
||||||
|
textPreviewObjectUrl,
|
||||||
|
} from "@/lib/attachments"
|
||||||
|
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
||||||
import { BacklogView } from "@/components/BacklogView"
|
import { BacklogView } from "@/components/BacklogView"
|
||||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
|
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
|
||||||
|
|
||||||
@ -295,6 +304,7 @@ export default function Home() {
|
|||||||
projects,
|
projects,
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
|
currentUser,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
addTask,
|
addTask,
|
||||||
@ -303,6 +313,7 @@ export default function Home() {
|
|||||||
selectTask,
|
selectTask,
|
||||||
addComment,
|
addComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
setCurrentUser,
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
@ -323,6 +334,7 @@ export default function Home() {
|
|||||||
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
||||||
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
|
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
|
||||||
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
|
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
|
||||||
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
|
||||||
const getTags = (taskLike: { tags?: unknown }) => {
|
const getTags = (taskLike: { tags?: unknown }) => {
|
||||||
if (!Array.isArray(taskLike.tags)) return [] as string[]
|
if (!Array.isArray(taskLike.tags)) return [] as string[]
|
||||||
@ -343,6 +355,27 @@ export default function Home() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCommentAuthor = (value: unknown): CommentAuthor => {
|
||||||
|
if (value === "assistant") {
|
||||||
|
return { id: "assistant", name: "Assistant", type: "assistant" }
|
||||||
|
}
|
||||||
|
if (value === "user") {
|
||||||
|
return { id: "legacy-user", name: "User", type: "human" }
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return { id: "legacy-user", name: "User", type: "human" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<CommentAuthor>
|
||||||
|
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human"
|
||||||
|
return {
|
||||||
|
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user",
|
||||||
|
name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
|
||||||
|
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
|
||||||
const units = ["B", "KB", "MB", "GB"]
|
const units = ["B", "KB", "MB", "GB"]
|
||||||
@ -371,13 +404,39 @@ export default function Home() {
|
|||||||
|
|
||||||
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
|
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
|
||||||
|
|
||||||
// Sync from server on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('>>> PAGE: useEffect for syncFromServer running')
|
let isMounted = true
|
||||||
syncFromServer().then(() => {
|
const loadSession = async () => {
|
||||||
console.log('>>> PAGE: syncFromServer completed')
|
try {
|
||||||
|
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!isMounted) return
|
||||||
|
setCurrentUser({
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
email: data.user.email,
|
||||||
})
|
})
|
||||||
}, [syncFromServer])
|
setAuthReady(true)
|
||||||
|
} catch {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession()
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [router, setCurrentUser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authReady) return
|
||||||
|
syncFromServer()
|
||||||
|
}, [authReady, syncFromServer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTaskId) {
|
if (selectedTaskId) {
|
||||||
@ -540,11 +599,20 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleAddComment = () => {
|
const handleAddComment = () => {
|
||||||
if (newComment.trim() && selectedTaskId) {
|
if (newComment.trim() && selectedTaskId) {
|
||||||
addComment(selectedTaskId, newComment.trim(), "user")
|
addComment(selectedTaskId, newComment.trim())
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
|
} finally {
|
||||||
|
setAuthReady(false)
|
||||||
|
router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files || [])
|
const files = Array.from(event.target.files || [])
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
@ -552,14 +620,19 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
const uploadedAt = new Date().toISOString()
|
const uploadedAt = new Date().toISOString()
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
files.map(async (file) => ({
|
files.map(async (file) => {
|
||||||
|
const type = inferAttachmentMimeType(file.name, file.type)
|
||||||
|
const rawDataUrl = await readFileAsDataUrl(file)
|
||||||
|
|
||||||
|
return {
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type || "application/octet-stream",
|
type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dataUrl: await readFileAsDataUrl(file),
|
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
|
||||||
uploadedAt,
|
uploadedAt,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
setEditedTask((prev) => {
|
setEditedTask((prev) => {
|
||||||
@ -576,6 +649,46 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAttachment = async (attachment: TaskAttachment) => {
|
||||||
|
try {
|
||||||
|
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
|
||||||
|
const objectUrl = isMarkdownAttachment(attachment.name, mimeType)
|
||||||
|
? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl)
|
||||||
|
: isTextPreviewAttachment(attachment.name, mimeType)
|
||||||
|
? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType)
|
||||||
|
: URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType))
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer")
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open attachment:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadAttachment = async (attachment: TaskAttachment) => {
|
||||||
|
try {
|
||||||
|
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
|
||||||
|
const blob = await blobFromDataUrl(attachment.dataUrl, mimeType)
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = objectUrl
|
||||||
|
link.download = attachment.name
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to download attachment:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-400">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -603,6 +716,23 @@ export default function Home() {
|
|||||||
<span className="hidden md:inline text-sm text-slate-400">
|
<span className="hidden md:inline text-sm text-slate-400">
|
||||||
{tasks.length} tasks · {allLabels.length} labels
|
{tasks.length} tasks · {allLabels.length} labels
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300">
|
||||||
|
{currentUser.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/settings")}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1109,27 +1239,26 @@ export default function Home() {
|
|||||||
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
|
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<a
|
<button
|
||||||
href={attachment.dataUrl}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => openAttachment(attachment)}
|
||||||
rel="noreferrer"
|
|
||||||
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
|
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
|
||||||
>
|
>
|
||||||
{attachment.name}
|
{attachment.name}
|
||||||
</a>
|
</button>
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
|
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<a
|
<button
|
||||||
href={attachment.dataUrl}
|
type="button"
|
||||||
download={attachment.name}
|
onClick={() => downloadAttachment(attachment)}
|
||||||
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
|
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
|
||||||
title="Download attachment"
|
title="Download attachment"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -1163,26 +1292,30 @@ export default function Home() {
|
|||||||
{!editedTask.comments || editedTask.comments.length === 0 ? (
|
{!editedTask.comments || editedTask.comments.length === 0 ? (
|
||||||
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
|
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
|
||||||
) : (
|
) : (
|
||||||
editedTask.comments.map((comment) => (
|
editedTask.comments.map((comment) => {
|
||||||
|
const author = getCommentAuthor(comment.author)
|
||||||
|
const isAssistant = author.type === "assistant"
|
||||||
|
const displayName = author.id === currentUser.id ? "You" : author.name
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className={`flex gap-3 p-3 rounded-lg ${
|
className={`flex gap-3 p-3 rounded-lg ${
|
||||||
comment.author === "assistant" ? "bg-blue-900/20" : "bg-slate-800/50"
|
isAssistant ? "bg-blue-900/20" : "bg-slate-800/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||||
comment.author === "assistant"
|
isAssistant
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "bg-slate-700 text-slate-300"
|
: "bg-slate-700 text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{comment.author === "assistant" ? "AI" : "You"}
|
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-sm font-medium text-slate-300">
|
<span className="text-sm font-medium text-slate-300">
|
||||||
{comment.author === "assistant" ? "Assistant" : "You"}
|
{isAssistant ? "Assistant" : displayName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
@ -1199,7 +1332,8 @@ export default function Home() {
|
|||||||
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
|
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
240
src/app/settings/page.tsx
Normal file
240
src/app/settings/page.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useTaskStore } from "@/stores/useTaskStore"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { setCurrentUser } = useTaskStore()
|
||||||
|
|
||||||
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("")
|
||||||
|
const [newPassword, setNewPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (!isMounted) return
|
||||||
|
|
||||||
|
setName(data.user.name || "")
|
||||||
|
setEmail(data.user.email || "")
|
||||||
|
setCurrentUser({
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
email: data.user.email,
|
||||||
|
})
|
||||||
|
setAuthReady(true)
|
||||||
|
} catch {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession()
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [router, setCurrentUser])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
const trimmedEmail = email.trim()
|
||||||
|
|
||||||
|
if (!trimmedName || !trimmedEmail) {
|
||||||
|
setError("Name and email are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword || confirmPassword || currentPassword) {
|
||||||
|
if (!currentPassword) {
|
||||||
|
setError("Current password is required to change password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!newPassword) {
|
||||||
|
setError("New password is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("New password and confirmation do not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
name: trimmedName,
|
||||||
|
email: trimmedEmail,
|
||||||
|
}
|
||||||
|
if (currentPassword) payload.currentPassword = currentPassword
|
||||||
|
if (newPassword) payload.newPassword = newPassword
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/account", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Failed to update account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentUser({
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
email: data.user.email,
|
||||||
|
})
|
||||||
|
setName(data.user.name)
|
||||||
|
setEmail(data.user.email)
|
||||||
|
setCurrentPassword("")
|
||||||
|
setNewPassword("")
|
||||||
|
setConfirmPassword("")
|
||||||
|
setSuccess("Account updated")
|
||||||
|
} catch {
|
||||||
|
setError("Failed to update account")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setIsLoggingOut(true)
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
|
router.replace("/login")
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-400">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<div className="max-w-2xl mx-auto p-4 md:p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" className="border-slate-700 text-slate-200 hover:bg-slate-800" onClick={() => router.push("/")}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Board
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
>
|
||||||
|
{isLoggingOut ? "Logging out..." : "Logout"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-slate-800 rounded-xl bg-slate-900/60 p-5 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Account Settings</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Update your profile and password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="Your display name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800 pt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-slate-200">Change Password</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Leave blank if you do not want to change it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="Current password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-300 mb-1">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
{success && <p className="text-sm text-emerald-400">{success}</p>}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -7,14 +7,25 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
blobFromDataUrl,
|
||||||
|
coerceDataUrlMimeType,
|
||||||
|
inferAttachmentMimeType,
|
||||||
|
isMarkdownAttachment,
|
||||||
|
isTextPreviewAttachment,
|
||||||
|
markdownPreviewObjectUrl,
|
||||||
|
textPreviewObjectUrl,
|
||||||
|
} from "@/lib/attachments"
|
||||||
import {
|
import {
|
||||||
useTaskStore,
|
useTaskStore,
|
||||||
type Comment as TaskComment,
|
type Comment as TaskComment,
|
||||||
|
type CommentAuthor,
|
||||||
type Priority,
|
type Priority,
|
||||||
type Task,
|
type Task,
|
||||||
type TaskAttachment,
|
type TaskAttachment,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type TaskType,
|
type TaskType,
|
||||||
|
type UserProfile,
|
||||||
} from "@/stores/useTaskStore"
|
} from "@/stores/useTaskStore"
|
||||||
|
|
||||||
const typeColors: Record<TaskType, string> = {
|
const typeColors: Record<TaskType, string> = {
|
||||||
@ -64,24 +75,53 @@ const getAttachments = (taskLike: { attachments?: unknown }) => {
|
|||||||
const getComments = (value: unknown): TaskComment[] => {
|
const getComments = (value: unknown): TaskComment[] => {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
return value
|
const normalized: TaskComment[] = []
|
||||||
.map((entry) => {
|
for (const entry of value) {
|
||||||
if (!entry || typeof entry !== "object") return null
|
if (!entry || typeof entry !== "object") continue
|
||||||
const comment = entry as Partial<TaskComment>
|
const comment = entry as Partial<TaskComment>
|
||||||
if (typeof comment.id !== "string" || typeof comment.text !== "string") return null
|
if (typeof comment.id !== "string" || typeof comment.text !== "string") continue
|
||||||
|
|
||||||
return {
|
normalized.push({
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
text: comment.text,
|
text: comment.text,
|
||||||
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
|
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
|
||||||
author: comment.author === "assistant" ? "assistant" : "user",
|
author: getCommentAuthor(comment.author),
|
||||||
replies: getComments(comment.replies),
|
replies: getComments(comment.replies),
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.filter((comment): comment is TaskComment => comment !== null)
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildComment = (text: string, author: "user" | "assistant" = "user"): TaskComment => ({
|
const getCommentAuthor = (value: unknown): CommentAuthor => {
|
||||||
|
if (value === "assistant") {
|
||||||
|
return { id: "assistant", name: "Assistant", type: "assistant" }
|
||||||
|
}
|
||||||
|
if (value === "user") {
|
||||||
|
return { id: "legacy-user", name: "User", type: "human" }
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return { id: "legacy-user", name: "User", type: "human" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<CommentAuthor>
|
||||||
|
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human"
|
||||||
|
return {
|
||||||
|
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user",
|
||||||
|
name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
|
||||||
|
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileToAuthor = (profile: UserProfile): CommentAuthor => ({
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
type: "human",
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildComment = (text: string, author: CommentAuthor): TaskComment => ({
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
text,
|
text,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@ -152,8 +192,10 @@ export default function TaskDetailPage() {
|
|||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
|
currentUser,
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
|
setCurrentUser,
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
@ -165,10 +207,40 @@ export default function TaskDetailPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
||||||
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
|
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
|
||||||
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (!isMounted) return
|
||||||
|
setCurrentUser({
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
email: data.user.email,
|
||||||
|
})
|
||||||
|
setAuthReady(true)
|
||||||
|
} catch {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession()
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [router, setCurrentUser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authReady) return
|
||||||
syncFromServer()
|
syncFromServer()
|
||||||
}, [syncFromServer])
|
}, [authReady, syncFromServer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTask) {
|
if (selectedTask) {
|
||||||
@ -203,14 +275,19 @@ export default function TaskDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const uploadedAt = new Date().toISOString()
|
const uploadedAt = new Date().toISOString()
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
files.map(async (file) => ({
|
files.map(async (file) => {
|
||||||
|
const type = inferAttachmentMimeType(file.name, file.type)
|
||||||
|
const rawDataUrl = await readFileAsDataUrl(file)
|
||||||
|
|
||||||
|
return {
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type || "application/octet-stream",
|
type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dataUrl: await readFileAsDataUrl(file),
|
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
|
||||||
uploadedAt,
|
uploadedAt,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
setEditedTask((prev) => {
|
setEditedTask((prev) => {
|
||||||
@ -230,9 +307,10 @@ export default function TaskDetailPage() {
|
|||||||
const handleAddComment = () => {
|
const handleAddComment = () => {
|
||||||
if (!editedTask || !newComment.trim()) return
|
if (!editedTask || !newComment.trim()) return
|
||||||
|
|
||||||
|
const actor = profileToAuthor(currentUser)
|
||||||
setEditedTask({
|
setEditedTask({
|
||||||
...editedTask,
|
...editedTask,
|
||||||
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")],
|
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)],
|
||||||
})
|
})
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}
|
}
|
||||||
@ -242,9 +320,10 @@ export default function TaskDetailPage() {
|
|||||||
const text = replyDrafts[parentId]?.trim()
|
const text = replyDrafts[parentId]?.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
|
const actor = profileToAuthor(currentUser)
|
||||||
setEditedTask({
|
setEditedTask({
|
||||||
...editedTask,
|
...editedTask,
|
||||||
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")),
|
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)),
|
||||||
})
|
})
|
||||||
|
|
||||||
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
||||||
@ -270,6 +349,38 @@ export default function TaskDetailPage() {
|
|||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAttachment = async (attachment: TaskAttachment) => {
|
||||||
|
try {
|
||||||
|
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
|
||||||
|
const objectUrl = isMarkdownAttachment(attachment.name, mimeType)
|
||||||
|
? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl)
|
||||||
|
: isTextPreviewAttachment(attachment.name, mimeType)
|
||||||
|
? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType)
|
||||||
|
: URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType))
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer")
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open attachment:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadAttachment = async (attachment: TaskAttachment) => {
|
||||||
|
try {
|
||||||
|
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
|
||||||
|
const blob = await blobFromDataUrl(attachment.dataUrl, mimeType)
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = objectUrl
|
||||||
|
link.download = attachment.name
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to download attachment:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!editedTask) return
|
if (!editedTask) return
|
||||||
if (!window.confirm("Delete this task?")) return
|
if (!window.confirm("Delete this task?")) return
|
||||||
@ -277,21 +388,33 @@ export default function TaskDetailPage() {
|
|||||||
router.push("/")
|
router.push("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
|
} finally {
|
||||||
|
setAuthReady(false)
|
||||||
|
router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] =>
|
const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] =>
|
||||||
comments.map((comment) => {
|
comments.map((comment) => {
|
||||||
const replies = getComments(comment.replies)
|
const replies = getComments(comment.replies)
|
||||||
const isReplying = !!openReplyEditors[comment.id]
|
const isReplying = !!openReplyEditors[comment.id]
|
||||||
const replyDraft = replyDrafts[comment.id] || ""
|
const replyDraft = replyDrafts[comment.id] || ""
|
||||||
|
const author = getCommentAuthor(comment.author)
|
||||||
|
const isAssistant = author.type === "assistant"
|
||||||
|
const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
|
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
|
||||||
<div className={`p-3 rounded-lg border ${comment.author === "assistant" ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
|
<div className={`p-3 rounded-lg border ${isAssistant ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${comment.author === "assistant" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}>
|
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${isAssistant ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}>
|
||||||
{comment.author === "assistant" ? "AI" : "You"}
|
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-slate-300 font-medium">{comment.author === "assistant" ? "Assistant" : "You"}</span>
|
<span className="text-sm text-slate-300 font-medium">{displayName}</span>
|
||||||
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
|
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -350,6 +473,14 @@ export default function TaskDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6 flex items-center justify-center">
|
||||||
|
<p className="text-slate-400">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedTask && !isLoading) {
|
if (!selectedTask && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
||||||
@ -376,7 +507,23 @@ export default function TaskDetailPage() {
|
|||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Back to Board
|
Back to Board
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500">Task URL: /tasks/{editedTask.id}</span>
|
<span className="text-xs text-slate-500">Task URL: /tasks/{editedTask.id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/settings")}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
||||||
|
>
|
||||||
|
Logout {currentUser.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-slate-800 rounded-xl bg-slate-900/60 p-4 md:p-6">
|
<div className="border border-slate-800 rounded-xl bg-slate-900/60 p-4 md:p-6">
|
||||||
@ -395,6 +542,9 @@ export default function TaskDetailPage() {
|
|||||||
onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })}
|
onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })}
|
||||||
className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
|
className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Created by {editedTask.createdByName || "Unknown"}{editedTask.updatedByName ? ` · Last updated by ${editedTask.updatedByName}` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
@ -554,27 +704,26 @@ export default function TaskDetailPage() {
|
|||||||
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
|
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<a
|
<button
|
||||||
href={attachment.dataUrl}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => openAttachment(attachment)}
|
||||||
rel="noreferrer"
|
|
||||||
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
|
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
|
||||||
>
|
>
|
||||||
{attachment.name}
|
{attachment.name}
|
||||||
</a>
|
</button>
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
|
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<a
|
<button
|
||||||
href={attachment.dataUrl}
|
type="button"
|
||||||
download={attachment.name}
|
onClick={() => downloadAttachment(attachment)}
|
||||||
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
|
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
|
||||||
title="Download attachment"
|
title="Download attachment"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
504
src/lib/attachments.ts
Normal file
504
src/lib/attachments.ts
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
export function inferAttachmentMimeType(fileName: string, declaredType?: string): string {
|
||||||
|
const safeDeclaredType = typeof declaredType === "string" ? declaredType.trim() : ""
|
||||||
|
if (safeDeclaredType && safeDeclaredType !== "application/octet-stream") {
|
||||||
|
return safeDeclaredType
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = fileName.toLowerCase().split(".").pop() || ""
|
||||||
|
const extensionToMime: Record<string, string> = {
|
||||||
|
md: "text/plain",
|
||||||
|
markdown: "text/plain",
|
||||||
|
txt: "text/plain",
|
||||||
|
log: "text/plain",
|
||||||
|
json: "application/json",
|
||||||
|
csv: "text/csv",
|
||||||
|
yml: "text/plain",
|
||||||
|
yaml: "text/plain",
|
||||||
|
html: "text/html",
|
||||||
|
htm: "text/html",
|
||||||
|
xml: "application/xml",
|
||||||
|
js: "text/javascript",
|
||||||
|
mjs: "text/javascript",
|
||||||
|
cjs: "text/javascript",
|
||||||
|
ts: "text/plain",
|
||||||
|
tsx: "text/plain",
|
||||||
|
jsx: "text/plain",
|
||||||
|
swift: "text/plain",
|
||||||
|
m: "text/plain",
|
||||||
|
mm: "text/plain",
|
||||||
|
pbxproj: "text/plain",
|
||||||
|
xcconfig: "text/plain",
|
||||||
|
strings: "text/plain",
|
||||||
|
plist: "application/xml",
|
||||||
|
stringsdict: "application/xml",
|
||||||
|
entitlements: "application/xml",
|
||||||
|
storyboard: "application/xml",
|
||||||
|
xib: "application/xml",
|
||||||
|
metal: "text/plain",
|
||||||
|
css: "text/css",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
pdf: "application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensionToMime[extension] || "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarkdownAttachment(fileName: string, mimeType?: string): boolean {
|
||||||
|
const extension = fileName.toLowerCase().split(".").pop() || ""
|
||||||
|
if (extension === "md" || extension === "markdown") return true
|
||||||
|
return (mimeType || "").toLowerCase() === "text/markdown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextPreviewAttachment(fileName: string, mimeType?: string): boolean {
|
||||||
|
const extension = fileName.toLowerCase().split(".").pop() || ""
|
||||||
|
const textLikeExtensions = new Set([
|
||||||
|
"txt",
|
||||||
|
"log",
|
||||||
|
"json",
|
||||||
|
"csv",
|
||||||
|
"xml",
|
||||||
|
"yml",
|
||||||
|
"yaml",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
"toml",
|
||||||
|
"properties",
|
||||||
|
"sql",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"ps1",
|
||||||
|
"py",
|
||||||
|
"rb",
|
||||||
|
"php",
|
||||||
|
"go",
|
||||||
|
"rs",
|
||||||
|
"java",
|
||||||
|
"kt",
|
||||||
|
"swift",
|
||||||
|
"pbxproj",
|
||||||
|
"plist",
|
||||||
|
"strings",
|
||||||
|
"stringsdict",
|
||||||
|
"xcconfig",
|
||||||
|
"entitlements",
|
||||||
|
"storyboard",
|
||||||
|
"xib",
|
||||||
|
"m",
|
||||||
|
"mm",
|
||||||
|
"metal",
|
||||||
|
"c",
|
||||||
|
"h",
|
||||||
|
"cpp",
|
||||||
|
"hpp",
|
||||||
|
"cs",
|
||||||
|
"js",
|
||||||
|
"mjs",
|
||||||
|
"cjs",
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"jsx",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"less",
|
||||||
|
"vue",
|
||||||
|
"svelte",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
])
|
||||||
|
|
||||||
|
const safeMimeType = (mimeType || "").toLowerCase()
|
||||||
|
if (textLikeExtensions.has(extension)) return true
|
||||||
|
if (safeMimeType.startsWith("text/")) return true
|
||||||
|
if (safeMimeType === "application/json" || safeMimeType === "application/xml") return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceDataUrlMimeType(dataUrl: string, mimeType: string): string {
|
||||||
|
if (!dataUrl.startsWith("data:")) return dataUrl
|
||||||
|
|
||||||
|
const commaIndex = dataUrl.indexOf(",")
|
||||||
|
if (commaIndex === -1) return dataUrl
|
||||||
|
|
||||||
|
const meta = dataUrl.slice(0, commaIndex)
|
||||||
|
const payload = dataUrl.slice(commaIndex + 1)
|
||||||
|
const hasBase64 = meta.includes(";base64")
|
||||||
|
const encodingSuffix = hasBase64 ? ";base64" : ""
|
||||||
|
|
||||||
|
return `data:${mimeType}${encodingSuffix},${payload}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blobFromDataUrl(dataUrl: string, mimeTypeOverride?: string): Promise<Blob> {
|
||||||
|
const response = await fetch(dataUrl)
|
||||||
|
const blob = await response.blob()
|
||||||
|
if (!mimeTypeOverride || blob.type === mimeTypeOverride) return blob
|
||||||
|
const buffer = await blob.arrayBuffer()
|
||||||
|
return new Blob([buffer], { type: mimeTypeOverride })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function textFromDataUrl(dataUrl: string): Promise<string> {
|
||||||
|
const response = await fetch(dataUrl)
|
||||||
|
return response.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeScriptString(value: string): string {
|
||||||
|
return JSON.stringify(value).replace(/<\/script/gi, "<\\/script")
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtension(fileName: string): string {
|
||||||
|
return fileName.toLowerCase().split(".").pop() || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonAttachment(fileName: string, mimeType?: string): boolean {
|
||||||
|
const extension = getExtension(fileName)
|
||||||
|
return extension === "json" || (mimeType || "").toLowerCase() === "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCsvAttachment(fileName: string, mimeType?: string): boolean {
|
||||||
|
const extension = getExtension(fileName)
|
||||||
|
return extension === "csv" || (mimeType || "").toLowerCase() === "text/csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHtmlAttachment(fileName: string, mimeType?: string): boolean {
|
||||||
|
const extension = getExtension(fileName)
|
||||||
|
const safeMimeType = (mimeType || "").toLowerCase()
|
||||||
|
return extension === "html" || extension === "htm" || safeMimeType === "text/html"
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCodeLanguage(fileName: string, mimeType?: string): string {
|
||||||
|
const extension = getExtension(fileName)
|
||||||
|
const safeMimeType = (mimeType || "").toLowerCase()
|
||||||
|
|
||||||
|
const extensionToLanguage: Record<string, string> = {
|
||||||
|
swift: "swift",
|
||||||
|
m: "objectivec",
|
||||||
|
mm: "objectivec",
|
||||||
|
h: "objectivec",
|
||||||
|
pbxproj: "ini",
|
||||||
|
xcconfig: "ini",
|
||||||
|
strings: "ini",
|
||||||
|
stringsdict: "xml",
|
||||||
|
plist: "xml",
|
||||||
|
entitlements: "xml",
|
||||||
|
storyboard: "xml",
|
||||||
|
xib: "xml",
|
||||||
|
metal: "cpp",
|
||||||
|
json: "json",
|
||||||
|
csv: "plaintext",
|
||||||
|
yml: "yaml",
|
||||||
|
yaml: "yaml",
|
||||||
|
xml: "xml",
|
||||||
|
html: "xml",
|
||||||
|
htm: "xml",
|
||||||
|
js: "javascript",
|
||||||
|
mjs: "javascript",
|
||||||
|
cjs: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
jsx: "javascript",
|
||||||
|
css: "css",
|
||||||
|
scss: "scss",
|
||||||
|
sass: "scss",
|
||||||
|
less: "less",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
fish: "bash",
|
||||||
|
ps1: "powershell",
|
||||||
|
py: "python",
|
||||||
|
rb: "ruby",
|
||||||
|
php: "php",
|
||||||
|
go: "go",
|
||||||
|
rs: "rust",
|
||||||
|
java: "java",
|
||||||
|
kt: "kotlin",
|
||||||
|
c: "c",
|
||||||
|
cpp: "cpp",
|
||||||
|
hpp: "cpp",
|
||||||
|
cs: "csharp",
|
||||||
|
sql: "sql",
|
||||||
|
vue: "xml",
|
||||||
|
svelte: "xml",
|
||||||
|
md: "markdown",
|
||||||
|
markdown: "markdown",
|
||||||
|
txt: "plaintext",
|
||||||
|
log: "plaintext",
|
||||||
|
ini: "ini",
|
||||||
|
cfg: "ini",
|
||||||
|
conf: "ini",
|
||||||
|
env: "ini",
|
||||||
|
toml: "ini",
|
||||||
|
properties: "ini",
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromExtension = extensionToLanguage[extension]
|
||||||
|
if (fromExtension) return fromExtension
|
||||||
|
|
||||||
|
if (safeMimeType === "application/json") return "json"
|
||||||
|
if (safeMimeType === "application/xml" || safeMimeType === "text/xml") return "xml"
|
||||||
|
if (safeMimeType === "text/html") return "xml"
|
||||||
|
if (safeMimeType === "text/css") return "css"
|
||||||
|
if (safeMimeType === "text/javascript") return "javascript"
|
||||||
|
|
||||||
|
return "plaintext"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line: string): string[] {
|
||||||
|
const cells: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let index = 0
|
||||||
|
let inQuotes = false
|
||||||
|
|
||||||
|
while (index < line.length) {
|
||||||
|
const char = line[index]
|
||||||
|
if (char === '"') {
|
||||||
|
const next = line[index + 1]
|
||||||
|
if (inQuotes && next === '"') {
|
||||||
|
current += '"'
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "," && !inQuotes) {
|
||||||
|
cells.push(current)
|
||||||
|
current = ""
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push(current)
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvTableHtml(content: string): string {
|
||||||
|
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(0, 200)
|
||||||
|
if (lines.length === 0) return '<p class="muted">No CSV rows to preview.</p>'
|
||||||
|
|
||||||
|
const rows = lines.map(parseCsvLine)
|
||||||
|
const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0)
|
||||||
|
const normalizedRows = rows.map((row) => [...row, ...Array.from({ length: maxColumns - row.length }, () => "")])
|
||||||
|
const header = normalizedRows[0]
|
||||||
|
const body = normalizedRows.slice(1)
|
||||||
|
|
||||||
|
const headerCells = header.map((cell) => `<th>${escapeHtml(cell || "Column")}</th>`).join("")
|
||||||
|
const bodyRows = body
|
||||||
|
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
return `<table><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJsonOrRaw(content: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(content), null, 2)
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextPreviewHtml(fileName: string, content: string, mimeType?: string): string {
|
||||||
|
const safeTitle = escapeHtml(fileName)
|
||||||
|
const safeMimeType = (mimeType || "").toLowerCase()
|
||||||
|
const isHtml = isHtmlAttachment(fileName, safeMimeType)
|
||||||
|
const isJson = isJsonAttachment(fileName, safeMimeType)
|
||||||
|
const isCsv = isCsvAttachment(fileName, safeMimeType)
|
||||||
|
const language = inferCodeLanguage(fileName, safeMimeType)
|
||||||
|
const displayedText = isJson ? prettyJsonOrRaw(content) : content
|
||||||
|
|
||||||
|
const sourceLiteral = safeScriptString(displayedText)
|
||||||
|
const htmlLiteral = safeScriptString(content)
|
||||||
|
const renderedCsv = isCsv ? csvTableHtml(content) : ""
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${safeTitle}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
|
.header { color: #94a3b8; font-size: 13px; margin-bottom: 14px; }
|
||||||
|
.panel { border: 1px solid #1e293b; background: #020617; border-radius: 10px; padding: 14px; margin-bottom: 14px; overflow: auto; }
|
||||||
|
.panel-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 8px; }
|
||||||
|
.copy-btn { border: 1px solid #334155; background: #111827; color: #e2e8f0; border-radius: 7px; font-size: 12px; padding: 4px 8px; cursor: pointer; }
|
||||||
|
.copy-btn:hover { border-color: #475569; }
|
||||||
|
.source-meta { color: #64748b; font-size: 12px; }
|
||||||
|
.code { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size: 13px; line-height: 1.55; }
|
||||||
|
.hljs { background: #020617 !important; border-radius: 8px; padding: 0 !important; }
|
||||||
|
.muted { color: #94a3b8; font-size: 13px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||||
|
th, td { border: 1px solid #334155; padding: 6px 8px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #111827; color: #f8fafc; position: sticky; top: 0; }
|
||||||
|
.iframe-wrap { border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; background: #ffffff; }
|
||||||
|
iframe { display: block; width: 100%; min-height: 360px; border: 0; background: #fff; }
|
||||||
|
details { border: 1px solid #1e293b; border-radius: 10px; background: #020617; padding: 10px 12px; }
|
||||||
|
summary { cursor: pointer; color: #cbd5e1; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<div class="header">Attachment Preview: ${safeTitle}${safeMimeType ? ` · ${escapeHtml(safeMimeType)}` : ""}</div>
|
||||||
|
${isHtml ? `<div class="iframe-wrap"><iframe sandbox="" id="html-frame"></iframe></div>` : ""}
|
||||||
|
${isCsv ? `<div class="panel">${renderedCsv}</div>` : ""}
|
||||||
|
${isHtml ? '<details><summary>View source</summary>' : ""}
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<button id="copy-source" type="button" class="copy-btn">Copy source</button>
|
||||||
|
<span id="source-meta" class="source-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<pre class="code"><code id="source-code" class="language-${language}"></code></pre>
|
||||||
|
</div>
|
||||||
|
${isHtml ? '</details>' : ""}
|
||||||
|
</main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const sourceText = ${sourceLiteral};
|
||||||
|
const rawHtml = ${htmlLiteral};
|
||||||
|
const sourceCodeElement = document.getElementById("source-code");
|
||||||
|
if (sourceCodeElement) sourceCodeElement.textContent = sourceText;
|
||||||
|
const sourceMetaElement = document.getElementById("source-meta");
|
||||||
|
if (sourceMetaElement) {
|
||||||
|
const lineCount = sourceText.length === 0 ? 0 : sourceText.split(/\\r?\\n/).length;
|
||||||
|
sourceMetaElement.textContent = "${escapeHtml(language)} · " + lineCount + " lines";
|
||||||
|
}
|
||||||
|
if (window.hljs && sourceCodeElement) {
|
||||||
|
try {
|
||||||
|
window.hljs.highlightElement(sourceCodeElement);
|
||||||
|
} catch (_error) {
|
||||||
|
// no-op fallback to plain text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const copyButton = document.getElementById("copy-source");
|
||||||
|
if (copyButton && navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
copyButton.addEventListener("click", async () => {
|
||||||
|
const originalLabel = copyButton.textContent || "Copy source";
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(sourceText);
|
||||||
|
copyButton.textContent = "Copied";
|
||||||
|
setTimeout(() => { copyButton.textContent = originalLabel; }, 1200);
|
||||||
|
} catch (_error) {
|
||||||
|
copyButton.textContent = "Copy failed";
|
||||||
|
setTimeout(() => { copyButton.textContent = originalLabel; }, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const htmlFrame = document.getElementById("html-frame");
|
||||||
|
if (htmlFrame) htmlFrame.srcdoc = rawHtml;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownPreviewHtml(fileName: string, markdown: string): string {
|
||||||
|
const safeTitle = fileName.replace(/[<>&"]/g, (char) => {
|
||||||
|
if (char === "<") return "<"
|
||||||
|
if (char === ">") return ">"
|
||||||
|
if (char === "&") return "&"
|
||||||
|
return """
|
||||||
|
})
|
||||||
|
const markdownLiteral = safeScriptString(markdown)
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${safeTitle}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
|
||||||
|
.container { max-width: 900px; margin: 0 auto; padding: 24px; }
|
||||||
|
.header { margin-bottom: 16px; color: #94a3b8; font-size: 14px; }
|
||||||
|
.markdown { line-height: 1.7; color: #e2e8f0; }
|
||||||
|
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { color: #f8fafc; margin-top: 1.3em; margin-bottom: 0.4em; }
|
||||||
|
.markdown pre { background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; overflow: auto; }
|
||||||
|
.markdown code { background: #020617; border: 1px solid #1e293b; border-radius: 6px; padding: 0.1em 0.35em; }
|
||||||
|
.markdown pre code { background: transparent; border: 0; padding: 0; }
|
||||||
|
.markdown a { color: #60a5fa; }
|
||||||
|
.markdown blockquote { border-left: 3px solid #334155; margin: 0.8em 0; padding-left: 12px; color: #cbd5e1; }
|
||||||
|
.markdown table { border-collapse: collapse; width: 100%; }
|
||||||
|
.markdown th, .markdown td { border: 1px solid #334155; padding: 6px 8px; }
|
||||||
|
.markdown hr { border: 0; border-top: 1px solid #334155; margin: 1.5em 0; }
|
||||||
|
.fallback { white-space: pre-wrap; background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; color: #e2e8f0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<div class="header">Markdown Preview: ${safeTitle}</div>
|
||||||
|
<article id="root" class="markdown"></article>
|
||||||
|
</main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const rawMarkdown = ${markdownLiteral};
|
||||||
|
const markdown = rawMarkdown.replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
const fallback = () => {
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.className = "fallback";
|
||||||
|
pre.textContent = rawMarkdown;
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(pre);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (window.marked && typeof window.marked.parse === "function") {
|
||||||
|
root.innerHTML = window.marked.parse(markdown);
|
||||||
|
if (window.hljs) {
|
||||||
|
root.querySelectorAll("pre code").forEach((block) => {
|
||||||
|
try {
|
||||||
|
window.hljs.highlightElement(block);
|
||||||
|
} catch (_error) {
|
||||||
|
// no-op fallback to plain rendered code block
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fallback();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
fallback();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markdownPreviewObjectUrl(fileName: string, dataUrl: string): Promise<string> {
|
||||||
|
const markdown = await textFromDataUrl(dataUrl)
|
||||||
|
const html = createMarkdownPreviewHtml(fileName, markdown)
|
||||||
|
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function textPreviewObjectUrl(fileName: string, dataUrl: string, mimeType?: string): Promise<string> {
|
||||||
|
const content = await textFromDataUrl(dataUrl)
|
||||||
|
const html = createTextPreviewHtml(fileName, content, mimeType)
|
||||||
|
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
|
||||||
|
}
|
||||||
295
src/lib/server/auth.ts
Normal file
295
src/lib/server/auth.ts
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const DATA_DIR = join(process.cwd(), "data");
|
||||||
|
const DB_FILE = join(DATA_DIR, "tasks.db");
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = "gantt_session";
|
||||||
|
const SESSION_HOURS_SHORT = 12;
|
||||||
|
const SESSION_DAYS_REMEMBER = 30;
|
||||||
|
|
||||||
|
type SqliteDb = InstanceType<typeof Database>;
|
||||||
|
let db: SqliteDb | null = null;
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserRow extends AuthUser {
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDb(): SqliteDb {
|
||||||
|
if (db) return db;
|
||||||
|
|
||||||
|
mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const database = new Database(DB_FILE);
|
||||||
|
database.pragma("journal_mode = WAL");
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
passwordHash TEXT NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
tokenHash TEXT NOT NULL UNIQUE,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
expiresAt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(tokenHash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(userId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db = database;
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPassword(password: string, salt?: string): string {
|
||||||
|
const safeSalt = salt || randomBytes(16).toString("hex");
|
||||||
|
const derived = scryptSync(password, safeSalt, 64).toString("hex");
|
||||||
|
return `scrypt$${safeSalt}$${derived}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||||
|
const [, salt, digest] = parts;
|
||||||
|
const candidate = hashPassword(password, salt);
|
||||||
|
const candidateDigest = candidate.split("$")[2];
|
||||||
|
const a = Buffer.from(digest, "hex");
|
||||||
|
const b = Buffer.from(candidateDigest, "hex");
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashSessionToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteExpiredSessions(database: SqliteDb) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
database.prepare("DELETE FROM sessions WHERE expiresAt <= ?").run(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUser(params: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}): AuthUser {
|
||||||
|
const database = getDb();
|
||||||
|
deleteExpiredSessions(database);
|
||||||
|
|
||||||
|
const name = params.name.trim();
|
||||||
|
const email = normalizeEmail(params.email);
|
||||||
|
const password = params.password;
|
||||||
|
|
||||||
|
if (name.length < 2) throw new Error("Name must be at least 2 characters");
|
||||||
|
if (!email.includes("@")) throw new Error("Invalid email");
|
||||||
|
if (password.length < 8) throw new Error("Password must be at least 8 characters");
|
||||||
|
|
||||||
|
const existing = database
|
||||||
|
.prepare("SELECT id FROM users WHERE email = ? LIMIT 1")
|
||||||
|
.get(email) as { id: string } | undefined;
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: AuthUser = {
|
||||||
|
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare("INSERT INTO users (id, name, email, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.run(user.id, user.name, user.email, hashPassword(password), user.createdAt);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticateUser(params: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}): AuthUser | null {
|
||||||
|
const database = getDb();
|
||||||
|
deleteExpiredSessions(database);
|
||||||
|
const email = normalizeEmail(params.email);
|
||||||
|
const row = database
|
||||||
|
.prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1")
|
||||||
|
.get(email) as UserRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
if (!verifyPassword(params.password, row.passwordHash)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserAccount(params: {
|
||||||
|
userId: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
}): AuthUser {
|
||||||
|
const database = getDb();
|
||||||
|
deleteExpiredSessions(database);
|
||||||
|
|
||||||
|
const row = database
|
||||||
|
.prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1")
|
||||||
|
.get(params.userId) as UserRow | undefined;
|
||||||
|
|
||||||
|
if (!row) throw new Error("User not found");
|
||||||
|
|
||||||
|
const requestedName = typeof params.name === "string" ? params.name.trim() : row.name;
|
||||||
|
const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email;
|
||||||
|
const currentPassword = params.currentPassword || "";
|
||||||
|
const newPassword = params.newPassword || "";
|
||||||
|
|
||||||
|
if (requestedName.length < 2) throw new Error("Name must be at least 2 characters");
|
||||||
|
if (!requestedEmail.includes("@")) throw new Error("Invalid email");
|
||||||
|
if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters");
|
||||||
|
|
||||||
|
const emailChanged = requestedEmail !== row.email;
|
||||||
|
const passwordChanged = newPassword.length > 0;
|
||||||
|
const needsPasswordCheck = emailChanged || passwordChanged;
|
||||||
|
|
||||||
|
if (needsPasswordCheck) {
|
||||||
|
if (!currentPassword) throw new Error("Current password is required");
|
||||||
|
if (!verifyPassword(currentPassword, row.passwordHash)) {
|
||||||
|
throw new Error("Current password is incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailChanged) {
|
||||||
|
const existing = database
|
||||||
|
.prepare("SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1")
|
||||||
|
.get(requestedEmail, row.id) as { id: string } | undefined;
|
||||||
|
if (existing) throw new Error("Email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash;
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare("UPDATE users SET name = ?, email = ?, passwordHash = ? WHERE id = ?")
|
||||||
|
.run(requestedName, requestedEmail, nextPasswordHash, row.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: requestedName,
|
||||||
|
email: requestedEmail,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserSession(userId: string, rememberMe: boolean): {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
} {
|
||||||
|
const database = getDb();
|
||||||
|
deleteExpiredSessions(database);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const ttlMs = rememberMe
|
||||||
|
? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000
|
||||||
|
: SESSION_HOURS_SHORT * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const createdAt = new Date(now).toISOString();
|
||||||
|
const expiresAt = new Date(now + ttlMs).toISOString();
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = hashSessionToken(token);
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare("INSERT INTO sessions (id, userId, tokenHash, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.run(`session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, userId, tokenHash, createdAt, expiresAt);
|
||||||
|
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeSession(token: string) {
|
||||||
|
const database = getDb();
|
||||||
|
const tokenHash = hashSessionToken(token);
|
||||||
|
database.prepare("DELETE FROM sessions WHERE tokenHash = ?").run(tokenHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserBySessionToken(token: string): AuthUser | null {
|
||||||
|
const database = getDb();
|
||||||
|
deleteExpiredSessions(database);
|
||||||
|
const tokenHash = hashSessionToken(token);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const row = database
|
||||||
|
.prepare(`
|
||||||
|
SELECT u.id, u.name, u.email, u.createdAt
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.userId
|
||||||
|
WHERE s.tokenHash = ? AND s.expiresAt > ?
|
||||||
|
LIMIT 1
|
||||||
|
`)
|
||||||
|
.get(tokenHash, now) as AuthUser | undefined;
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSessionCookie(token: string, rememberMe: boolean) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const baseOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (rememberMe) {
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
||||||
|
...baseOptions,
|
||||||
|
maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session cookie (clears on browser close)
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionCookie() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionTokenFromCookies(): Promise<string | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthenticatedUser(): Promise<AuthUser | null> {
|
||||||
|
const token = await getSessionTokenFromCookies();
|
||||||
|
if (!token) return null;
|
||||||
|
return getUserBySessionToken(token);
|
||||||
|
}
|
||||||
@ -15,10 +15,17 @@ export interface TaskComment {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: "user" | "assistant";
|
author: TaskCommentAuthor | "user" | "assistant";
|
||||||
replies?: TaskComment[];
|
replies?: TaskComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskCommentAuthor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
type: "human" | "assistant";
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -30,6 +37,10 @@ export interface Task {
|
|||||||
sprintId?: string;
|
sprintId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
createdById?: string;
|
||||||
|
createdByName?: string;
|
||||||
|
updatedById?: string;
|
||||||
|
updatedByName?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
comments: TaskComment[];
|
comments: TaskComment[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@ -126,13 +137,43 @@ function normalizeComments(comments: unknown): TaskComment[] {
|
|||||||
id: value.id,
|
id: value.id,
|
||||||
text: value.text,
|
text: value.text,
|
||||||
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
|
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
|
||||||
author: value.author === "assistant" ? "assistant" : "user",
|
author: normalizeCommentAuthor(value.author),
|
||||||
replies: normalizeComments(value.replies),
|
replies: normalizeComments(value.replies),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((comment): comment is TaskComment => comment !== null);
|
.filter((comment): comment is TaskComment => comment !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
|
||||||
|
if (author === "assistant") {
|
||||||
|
return { id: "assistant", name: "Assistant", type: "assistant" };
|
||||||
|
}
|
||||||
|
if (author === "user") {
|
||||||
|
return { id: "legacy-user", name: "User", type: "human" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!author || typeof author !== "object") {
|
||||||
|
return { id: "legacy-user", name: "User", type: "human" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = author as Partial<TaskCommentAuthor>;
|
||||||
|
const type: TaskCommentAuthor["type"] =
|
||||||
|
value.type === "assistant" || value.id === "assistant" ? "assistant" : "human";
|
||||||
|
const id = typeof value.id === "string" && value.id.trim().length > 0
|
||||||
|
? value.id
|
||||||
|
: type === "assistant"
|
||||||
|
? "assistant"
|
||||||
|
: "legacy-user";
|
||||||
|
const name = typeof value.name === "string" && value.name.trim().length > 0
|
||||||
|
? value.name.trim()
|
||||||
|
: type === "assistant"
|
||||||
|
? "Assistant"
|
||||||
|
: "User";
|
||||||
|
const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined;
|
||||||
|
|
||||||
|
return { id, name, email, type };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTask(task: Partial<Task>): Task {
|
function normalizeTask(task: Partial<Task>): Task {
|
||||||
return {
|
return {
|
||||||
id: String(task.id ?? Date.now()),
|
id: String(task.id ?? Date.now()),
|
||||||
@ -145,6 +186,10 @@ function normalizeTask(task: Partial<Task>): Task {
|
|||||||
sprintId: task.sprintId || undefined,
|
sprintId: task.sprintId || undefined,
|
||||||
createdAt: task.createdAt || new Date().toISOString(),
|
createdAt: task.createdAt || new Date().toISOString(),
|
||||||
updatedAt: task.updatedAt || new Date().toISOString(),
|
updatedAt: task.updatedAt || new Date().toISOString(),
|
||||||
|
createdById: typeof task.createdById === "string" && task.createdById.trim().length > 0 ? task.createdById : undefined,
|
||||||
|
createdByName: typeof task.createdByName === "string" && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
|
||||||
|
updatedById: typeof task.updatedById === "string" && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
|
||||||
|
updatedByName: typeof task.updatedByName === "string" && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
|
||||||
dueDate: task.dueDate || undefined,
|
dueDate: task.dueDate || undefined,
|
||||||
comments: normalizeComments(task.comments),
|
comments: normalizeComments(task.comments),
|
||||||
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||||
@ -183,8 +228,8 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
|
|||||||
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
|
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
|
||||||
`);
|
`);
|
||||||
const insertTask = database.prepare(`
|
const insertTask = database.prepare(`
|
||||||
INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, dueDate, comments, tags, attachments)
|
INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, updatedById, updatedByName, dueDate, comments, tags, attachments)
|
||||||
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags, @attachments)
|
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @updatedById, @updatedByName, @dueDate, @comments, @tags, @attachments)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const project of payload.projects) {
|
for (const project of payload.projects) {
|
||||||
@ -214,6 +259,10 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
|
|||||||
insertTask.run({
|
insertTask.run({
|
||||||
...task,
|
...task,
|
||||||
sprintId: task.sprintId ?? null,
|
sprintId: task.sprintId ?? null,
|
||||||
|
createdById: task.createdById ?? null,
|
||||||
|
createdByName: task.createdByName ?? null,
|
||||||
|
updatedById: task.updatedById ?? null,
|
||||||
|
updatedByName: task.updatedByName ?? null,
|
||||||
dueDate: task.dueDate ?? null,
|
dueDate: task.dueDate ?? null,
|
||||||
comments: JSON.stringify(task.comments ?? []),
|
comments: JSON.stringify(task.comments ?? []),
|
||||||
tags: JSON.stringify(task.tags ?? []),
|
tags: JSON.stringify(task.tags ?? []),
|
||||||
@ -280,6 +329,10 @@ function getDb(): SqliteDb {
|
|||||||
sprintId TEXT,
|
sprintId TEXT,
|
||||||
createdAt TEXT NOT NULL,
|
createdAt TEXT NOT NULL,
|
||||||
updatedAt TEXT NOT NULL,
|
updatedAt TEXT NOT NULL,
|
||||||
|
createdById TEXT,
|
||||||
|
createdByName TEXT,
|
||||||
|
updatedById TEXT,
|
||||||
|
updatedByName TEXT,
|
||||||
dueDate TEXT,
|
dueDate TEXT,
|
||||||
comments TEXT NOT NULL DEFAULT '[]',
|
comments TEXT NOT NULL DEFAULT '[]',
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
@ -296,6 +349,18 @@ function getDb(): SqliteDb {
|
|||||||
if (!taskColumns.some((column) => column.name === "attachments")) {
|
if (!taskColumns.some((column) => column.name === "attachments")) {
|
||||||
database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';");
|
database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';");
|
||||||
}
|
}
|
||||||
|
if (!taskColumns.some((column) => column.name === "createdById")) {
|
||||||
|
database.exec("ALTER TABLE tasks ADD COLUMN createdById TEXT;");
|
||||||
|
}
|
||||||
|
if (!taskColumns.some((column) => column.name === "createdByName")) {
|
||||||
|
database.exec("ALTER TABLE tasks ADD COLUMN createdByName TEXT;");
|
||||||
|
}
|
||||||
|
if (!taskColumns.some((column) => column.name === "updatedById")) {
|
||||||
|
database.exec("ALTER TABLE tasks ADD COLUMN updatedById TEXT;");
|
||||||
|
}
|
||||||
|
if (!taskColumns.some((column) => column.name === "updatedByName")) {
|
||||||
|
database.exec("ALTER TABLE tasks ADD COLUMN updatedByName TEXT;");
|
||||||
|
}
|
||||||
|
|
||||||
seedIfEmpty(database);
|
seedIfEmpty(database);
|
||||||
db = database;
|
db = database;
|
||||||
@ -335,6 +400,10 @@ export function getData(): DataStore {
|
|||||||
sprintId: string | null;
|
sprintId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
createdById: string | null;
|
||||||
|
createdByName: string | null;
|
||||||
|
updatedById: string | null;
|
||||||
|
updatedByName: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
comments: string | null;
|
comments: string | null;
|
||||||
tags: string | null;
|
tags: string | null;
|
||||||
@ -370,6 +439,10 @@ export function getData(): DataStore {
|
|||||||
sprintId: task.sprintId ?? undefined,
|
sprintId: task.sprintId ?? undefined,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
updatedAt: task.updatedAt,
|
updatedAt: task.updatedAt,
|
||||||
|
createdById: task.createdById ?? undefined,
|
||||||
|
createdByName: task.createdByName ?? undefined,
|
||||||
|
updatedById: task.updatedById ?? undefined,
|
||||||
|
updatedByName: task.updatedByName ?? undefined,
|
||||||
dueDate: task.dueDate ?? undefined,
|
dueDate: task.dueDate ?? undefined,
|
||||||
comments: normalizeComments(safeParseArray(task.comments, [])),
|
comments: normalizeComments(safeParseArray(task.comments, [])),
|
||||||
tags: safeParseArray(task.tags, []),
|
tags: safeParseArray(task.tags, []),
|
||||||
|
|||||||
@ -21,10 +21,23 @@ export interface Comment {
|
|||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
author: 'user' | 'assistant'
|
author: CommentAuthor | 'user' | 'assistant'
|
||||||
replies?: Comment[]
|
replies?: Comment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentAuthor {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
type: 'human' | 'assistant'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskAttachment {
|
export interface TaskAttachment {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -45,6 +58,10 @@ export interface Task {
|
|||||||
sprintId?: string
|
sprintId?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
createdById?: string
|
||||||
|
createdByName?: string
|
||||||
|
updatedById?: string
|
||||||
|
updatedByName?: string
|
||||||
dueDate?: string
|
dueDate?: string
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
@ -66,12 +83,14 @@ interface TaskStore {
|
|||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
selectedTaskId: string | null
|
selectedTaskId: string | null
|
||||||
selectedSprintId: string | null
|
selectedSprintId: string | null
|
||||||
|
currentUser: UserProfile
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
lastSynced: number | null
|
lastSynced: number | null
|
||||||
|
|
||||||
// Sync actions
|
// Sync actions
|
||||||
syncFromServer: () => Promise<void>
|
syncFromServer: () => Promise<void>
|
||||||
syncToServer: () => Promise<void>
|
syncToServer: () => Promise<void>
|
||||||
|
setCurrentUser: (user: Partial<UserProfile>) => void
|
||||||
|
|
||||||
// Project actions
|
// Project actions
|
||||||
addProject: (name: string, description?: string) => void
|
addProject: (name: string, description?: string) => void
|
||||||
@ -93,7 +112,7 @@ interface TaskStore {
|
|||||||
getTasksBySprint: (sprintId: string) => Task[]
|
getTasksBySprint: (sprintId: string) => Task[]
|
||||||
|
|
||||||
// Comment actions
|
// Comment actions
|
||||||
addComment: (taskId: string, text: string, author: 'user' | 'assistant') => void
|
addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void
|
||||||
deleteComment: (taskId: string, commentId: string) => void
|
deleteComment: (taskId: string, commentId: string) => void
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@ -386,6 +405,72 @@ const defaultTasks: Task[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const createLocalUserProfile = (): UserProfile => ({
|
||||||
|
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: 'Local User',
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultCurrentUser = createLocalUserProfile()
|
||||||
|
|
||||||
|
const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => {
|
||||||
|
if (!value || typeof value !== 'object') return fallback
|
||||||
|
const candidate = value as Partial<UserProfile>
|
||||||
|
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 ? candidate.id : fallback.id
|
||||||
|
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 ? candidate.name.trim() : fallback.name
|
||||||
|
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
|
||||||
|
return { id, name, email }
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
type: 'human',
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistantAuthor: CommentAuthor = {
|
||||||
|
id: 'assistant',
|
||||||
|
name: 'Assistant',
|
||||||
|
type: 'assistant',
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCommentAuthor = (value: unknown): CommentAuthor => {
|
||||||
|
if (value === 'assistant') return assistantAuthor
|
||||||
|
if (value === 'user') {
|
||||||
|
return {
|
||||||
|
id: 'legacy-user',
|
||||||
|
name: 'User',
|
||||||
|
type: 'human',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return {
|
||||||
|
id: 'legacy-user',
|
||||||
|
name: 'User',
|
||||||
|
type: 'human',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<CommentAuthor>
|
||||||
|
const type: CommentAuthor['type'] =
|
||||||
|
candidate.type === 'assistant' || candidate.id === 'assistant' ? 'assistant' : 'human'
|
||||||
|
|
||||||
|
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0
|
||||||
|
? candidate.id
|
||||||
|
: type === 'assistant'
|
||||||
|
? 'assistant'
|
||||||
|
: 'legacy-user'
|
||||||
|
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0
|
||||||
|
? candidate.name.trim()
|
||||||
|
: type === 'assistant'
|
||||||
|
? 'Assistant'
|
||||||
|
: 'User'
|
||||||
|
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
|
||||||
|
|
||||||
|
return { id, name, email, type }
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeComments = (value: unknown): Comment[] => {
|
const normalizeComments = (value: unknown): Comment[] => {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
@ -395,12 +480,11 @@ const normalizeComments = (value: unknown): Comment[] => {
|
|||||||
const candidate = entry as Partial<Comment>
|
const candidate = entry as Partial<Comment>
|
||||||
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null
|
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null
|
||||||
|
|
||||||
const author = candidate.author === 'assistant' ? 'assistant' : 'user'
|
|
||||||
return {
|
return {
|
||||||
id: candidate.id,
|
id: candidate.id,
|
||||||
text: candidate.text,
|
text: candidate.text,
|
||||||
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
|
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
|
||||||
author,
|
author: normalizeCommentAuthor(candidate.author),
|
||||||
replies: normalizeComments(candidate.replies),
|
replies: normalizeComments(candidate.replies),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -435,6 +519,10 @@ const normalizeTask = (task: Task): Task => ({
|
|||||||
comments: normalizeComments(task.comments),
|
comments: normalizeComments(task.comments),
|
||||||
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [],
|
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [],
|
||||||
attachments: normalizeAttachments(task.attachments),
|
attachments: normalizeAttachments(task.attachments),
|
||||||
|
createdById: typeof task.createdById === 'string' && task.createdById.trim().length > 0 ? task.createdById : undefined,
|
||||||
|
createdByName: typeof task.createdByName === 'string' && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
|
||||||
|
updatedById: typeof task.updatedById === 'string' && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
|
||||||
|
updatedByName: typeof task.updatedByName === 'string' && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
|
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
|
||||||
@ -478,6 +566,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
selectedProjectId: '1',
|
selectedProjectId: '1',
|
||||||
selectedTaskId: null,
|
selectedTaskId: null,
|
||||||
selectedSprintId: null,
|
selectedSprintId: null,
|
||||||
|
currentUser: defaultCurrentUser,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastSynced: null,
|
lastSynced: null,
|
||||||
|
|
||||||
@ -523,6 +612,10 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
set({ lastSynced: Date.now() })
|
set({ lastSynced: Date.now() })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCurrentUser: (user) => {
|
||||||
|
set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) }))
|
||||||
|
},
|
||||||
|
|
||||||
addProject: (name, description) => {
|
addProject: (name, description) => {
|
||||||
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||||
const newProject: Project = {
|
const newProject: Project = {
|
||||||
@ -566,11 +659,16 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
|
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
|
||||||
|
|
||||||
addTask: (task) => {
|
addTask: (task) => {
|
||||||
|
const actor = profileToCommentAuthor(get().currentUser)
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
...task,
|
...task,
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdById: actor.id,
|
||||||
|
createdByName: actor.name,
|
||||||
|
updatedById: actor.id,
|
||||||
|
updatedByName: actor.name,
|
||||||
comments: normalizeComments([]),
|
comments: normalizeComments([]),
|
||||||
attachments: normalizeAttachments(task.attachments),
|
attachments: normalizeAttachments(task.attachments),
|
||||||
}
|
}
|
||||||
@ -584,6 +682,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
updateTask: (id, updates) => {
|
updateTask: (id, updates) => {
|
||||||
console.log('updateTask called:', id, updates)
|
console.log('updateTask called:', id, updates)
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const actor = profileToCommentAuthor(state.currentUser)
|
||||||
const newTasks = state.tasks.map((t) =>
|
const newTasks = state.tasks.map((t) =>
|
||||||
t.id === id
|
t.id === id
|
||||||
? normalizeTask({
|
? normalizeTask({
|
||||||
@ -592,6 +691,8 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments,
|
comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments,
|
||||||
attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments,
|
attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedById: actor.id,
|
||||||
|
updatedByName: actor.name,
|
||||||
} as Task)
|
} as Task)
|
||||||
: t
|
: t
|
||||||
)
|
)
|
||||||
@ -661,17 +762,25 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addComment: (taskId, text, author) => {
|
addComment: (taskId, text, author) => {
|
||||||
|
const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser))
|
||||||
const newComment: Comment = {
|
const newComment: Comment = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
text,
|
text,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
author,
|
author: actor,
|
||||||
replies: [],
|
replies: [],
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const updater = profileToCommentAuthor(state.currentUser)
|
||||||
const newTasks = state.tasks.map((t) =>
|
const newTasks = state.tasks.map((t) =>
|
||||||
t.id === taskId
|
t.id === taskId
|
||||||
? { ...t, comments: [...normalizeComments(t.comments), newComment], updatedAt: new Date().toISOString() }
|
? {
|
||||||
|
...t,
|
||||||
|
comments: [...normalizeComments(t.comments), newComment],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedById: updater.id,
|
||||||
|
updatedByName: updater.name,
|
||||||
|
}
|
||||||
: t
|
: t
|
||||||
)
|
)
|
||||||
syncToServer(state.projects, newTasks, state.sprints)
|
syncToServer(state.projects, newTasks, state.sprints)
|
||||||
@ -681,9 +790,16 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
|
|
||||||
deleteComment: (taskId, commentId) => {
|
deleteComment: (taskId, commentId) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const updater = profileToCommentAuthor(state.currentUser)
|
||||||
const newTasks = state.tasks.map((t) =>
|
const newTasks = state.tasks.map((t) =>
|
||||||
t.id === taskId
|
t.id === taskId
|
||||||
? { ...t, comments: removeCommentFromThread(normalizeComments(t.comments), commentId), updatedAt: new Date().toISOString() }
|
? {
|
||||||
|
...t,
|
||||||
|
comments: removeCommentFromThread(normalizeComments(t.comments), commentId),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedById: updater.id,
|
||||||
|
updatedByName: updater.name,
|
||||||
|
}
|
||||||
: t
|
: t
|
||||||
)
|
)
|
||||||
syncToServer(state.projects, newTasks, state.sprints)
|
syncToServer(state.projects, newTasks, state.sprints)
|
||||||
@ -706,7 +822,8 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
{
|
{
|
||||||
name: 'task-store',
|
name: 'task-store',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
// Only persist UI state, not data
|
// Persist user identity and UI state, not task data
|
||||||
|
currentUser: state.currentUser,
|
||||||
selectedProjectId: state.selectedProjectId,
|
selectedProjectId: state.selectedProjectId,
|
||||||
selectedTaskId: state.selectedTaskId,
|
selectedTaskId: state.selectedTaskId,
|
||||||
selectedSprintId: state.selectedSprintId,
|
selectedSprintId: state.selectedSprintId,
|
||||||
@ -718,6 +835,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
console.log('>>> PERSIST: Rehydration error:', error)
|
console.log('>>> PERSIST: Rehydration error:', error)
|
||||||
} else {
|
} else {
|
||||||
console.log('>>> PERSIST: Rehydrated state:', {
|
console.log('>>> PERSIST: Rehydrated state:', {
|
||||||
|
currentUser: state?.currentUser?.name,
|
||||||
selectedProjectId: state?.selectedProjectId,
|
selectedProjectId: state?.selectedProjectId,
|
||||||
selectedTaskId: state?.selectedTaskId,
|
selectedTaskId: state?.selectedTaskId,
|
||||||
tasksCount: state?.tasks?.length,
|
tasksCount: state?.tasks?.length,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user