Compare commits
No commits in common. "ed1d2d956a309198e7b4a7d2130d75eb0b110b78" and "5ba0edd85655c04bf4f158628cf295e930d1b67c" have entirely different histories.
ed1d2d956a
...
5ba0edd856
65
README.md
65
README.md
@ -4,21 +4,9 @@ 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:
|
||||||
@ -33,68 +21,18 @@ 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 page
|
- Task Detail modal
|
||||||
- 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:
|
||||||
@ -147,7 +85,6 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`.
|
Open `http://localhost:3000`.
|
||||||
Task URLs follow `http://localhost:3000/tasks/{taskId}`.
|
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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,16 +1,11 @@
|
|||||||
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) {
|
||||||
@ -22,11 +17,6 @@ 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;
|
||||||
@ -43,34 +33,19 @@ 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] = {
|
data.tasks[existingIndex] = { ...task, updatedAt: new Date().toISOString() };
|
||||||
...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.map((entry) => ({
|
data.tasks = tasks;
|
||||||
...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);
|
||||||
@ -84,11 +59,6 @@ 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);
|
||||||
@ -99,3 +69,4 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
"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,16 +22,7 @@ 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 {
|
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
|
||||||
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"
|
||||||
|
|
||||||
@ -304,7 +295,6 @@ export default function Home() {
|
|||||||
projects,
|
projects,
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
currentUser,
|
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
addTask,
|
addTask,
|
||||||
@ -313,7 +303,6 @@ export default function Home() {
|
|||||||
selectTask,
|
selectTask,
|
||||||
addComment,
|
addComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
setCurrentUser,
|
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
@ -334,7 +323,6 @@ 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[]
|
||||||
@ -355,27 +343,6 @@ 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"]
|
||||||
@ -404,39 +371,13 @@ 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(() => {
|
||||||
let isMounted = true
|
console.log('>>> PAGE: useEffect for syncFromServer running')
|
||||||
const loadSession = async () => {
|
syncFromServer().then(() => {
|
||||||
try {
|
console.log('>>> PAGE: syncFromServer completed')
|
||||||
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)
|
}, [syncFromServer])
|
||||||
} 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) {
|
||||||
@ -599,20 +540,11 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleAddComment = () => {
|
const handleAddComment = () => {
|
||||||
if (newComment.trim() && selectedTaskId) {
|
if (newComment.trim() && selectedTaskId) {
|
||||||
addComment(selectedTaskId, newComment.trim())
|
addComment(selectedTaskId, newComment.trim(), "user")
|
||||||
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
|
||||||
@ -620,19 +552,14 @@ 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,
|
type: file.type || "application/octet-stream",
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
|
dataUrl: await readFileAsDataUrl(file),
|
||||||
uploadedAt,
|
uploadedAt,
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
setEditedTask((prev) => {
|
setEditedTask((prev) => {
|
||||||
@ -649,46 +576,6 @@ 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 */}
|
||||||
@ -716,23 +603,6 @@ 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>
|
||||||
@ -1239,26 +1109,27 @@ 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">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={attachment.dataUrl}
|
||||||
onClick={() => openAttachment(attachment)}
|
target="_blank"
|
||||||
|
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}
|
||||||
</button>
|
</a>
|
||||||
<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">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={attachment.dataUrl}
|
||||||
onClick={() => downloadAttachment(attachment)}
|
download={attachment.name}
|
||||||
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" />
|
||||||
</button>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -1292,30 +1163,26 @@ 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 ${
|
||||||
isAssistant ? "bg-blue-900/20" : "bg-slate-800/50"
|
comment.author === "assistant" ? "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 ${
|
||||||
isAssistant
|
comment.author === "assistant"
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "bg-slate-700 text-slate-300"
|
: "bg-slate-700 text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
|
{comment.author === "assistant" ? "AI" : "You"}
|
||||||
</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">
|
||||||
{isAssistant ? "Assistant" : displayName}
|
{comment.author === "assistant" ? "Assistant" : "You"}
|
||||||
</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">
|
||||||
@ -1332,8 +1199,7 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
"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,25 +7,14 @@ 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> = {
|
||||||
@ -75,53 +64,24 @@ 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 []
|
||||||
|
|
||||||
const normalized: TaskComment[] = []
|
return value
|
||||||
for (const entry of value) {
|
.map((entry) => {
|
||||||
if (!entry || typeof entry !== "object") continue
|
if (!entry || typeof entry !== "object") return null
|
||||||
const comment = entry as Partial<TaskComment>
|
const comment = entry as Partial<TaskComment>
|
||||||
if (typeof comment.id !== "string" || typeof comment.text !== "string") continue
|
if (typeof comment.id !== "string" || typeof comment.text !== "string") return null
|
||||||
|
|
||||||
normalized.push({
|
return {
|
||||||
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: getCommentAuthor(comment.author),
|
author: comment.author === "assistant" ? "assistant" : "user",
|
||||||
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(),
|
||||||
@ -192,10 +152,8 @@ export default function TaskDetailPage() {
|
|||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
currentUser,
|
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
setCurrentUser,
|
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
@ -207,40 +165,10 @@ 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()
|
||||||
}, [authReady, syncFromServer])
|
}, [syncFromServer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTask) {
|
if (selectedTask) {
|
||||||
@ -275,19 +203,14 @@ 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,
|
type: file.type || "application/octet-stream",
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
|
dataUrl: await readFileAsDataUrl(file),
|
||||||
uploadedAt,
|
uploadedAt,
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
setEditedTask((prev) => {
|
setEditedTask((prev) => {
|
||||||
@ -307,10 +230,9 @@ 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(), actor)],
|
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")],
|
||||||
})
|
})
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}
|
}
|
||||||
@ -320,10 +242,9 @@ 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, actor)),
|
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")),
|
||||||
})
|
})
|
||||||
|
|
||||||
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
|
||||||
@ -349,38 +270,6 @@ 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
|
||||||
@ -388,33 +277,21 @@ 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 ${isAssistant ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
|
<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="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 ${isAssistant ? "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 ${comment.author === "assistant" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}>
|
||||||
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
|
{comment.author === "assistant" ? "AI" : "You"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-slate-300 font-medium">{displayName}</span>
|
<span className="text-sm text-slate-300 font-medium">{comment.author === "assistant" ? "Assistant" : "You"}</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">
|
||||||
@ -473,14 +350,6 @@ 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">
|
||||||
@ -507,23 +376,7 @@ 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">
|
||||||
@ -542,9 +395,6 @@ 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>
|
||||||
@ -704,26 +554,27 @@ 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">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={attachment.dataUrl}
|
||||||
onClick={() => openAttachment(attachment)}
|
target="_blank"
|
||||||
|
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}
|
||||||
</button>
|
</a>
|
||||||
<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">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={attachment.dataUrl}
|
||||||
onClick={() => downloadAttachment(attachment)}
|
download={attachment.name}
|
||||||
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" />
|
||||||
</button>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@ -1,504 +0,0 @@
|
|||||||
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" }))
|
|
||||||
}
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
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,17 +15,10 @@ export interface TaskComment {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: TaskCommentAuthor | "user" | "assistant";
|
author: "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;
|
||||||
@ -37,10 +30,6 @@ 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[];
|
||||||
@ -137,43 +126,13 @@ 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: normalizeCommentAuthor(value.author),
|
author: value.author === "assistant" ? "assistant" : "user",
|
||||||
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()),
|
||||||
@ -186,10 +145,6 @@ 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") : [],
|
||||||
@ -228,8 +183,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, createdById, createdByName, updatedById, updatedByName, dueDate, comments, tags, attachments)
|
INSERT INTO tasks (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)
|
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags, @attachments)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const project of payload.projects) {
|
for (const project of payload.projects) {
|
||||||
@ -259,10 +214,6 @@ 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 ?? []),
|
||||||
@ -329,10 +280,6 @@ 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 '[]',
|
||||||
@ -349,18 +296,6 @@ 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;
|
||||||
@ -400,10 +335,6 @@ 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;
|
||||||
@ -439,10 +370,6 @@ 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,23 +21,10 @@ export interface Comment {
|
|||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
author: CommentAuthor | 'user' | 'assistant'
|
author: '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
|
||||||
@ -58,10 +45,6 @@ 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[]
|
||||||
@ -83,14 +66,12 @@ 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
|
||||||
@ -112,7 +93,7 @@ interface TaskStore {
|
|||||||
getTasksBySprint: (sprintId: string) => Task[]
|
getTasksBySprint: (sprintId: string) => Task[]
|
||||||
|
|
||||||
// Comment actions
|
// Comment actions
|
||||||
addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void
|
addComment: (taskId: string, text: string, author: 'user' | 'assistant') => void
|
||||||
deleteComment: (taskId: string, commentId: string) => void
|
deleteComment: (taskId: string, commentId: string) => void
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@ -405,72 +386,6 @@ 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 []
|
||||||
|
|
||||||
@ -480,11 +395,12 @@ 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: normalizeCommentAuthor(candidate.author),
|
author,
|
||||||
replies: normalizeComments(candidate.replies),
|
replies: normalizeComments(candidate.replies),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -519,10 +435,6 @@ 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[] =>
|
||||||
@ -566,7 +478,6 @@ 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,
|
||||||
|
|
||||||
@ -612,10 +523,6 @@ 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 = {
|
||||||
@ -659,16 +566,11 @@ 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),
|
||||||
}
|
}
|
||||||
@ -682,7 +584,6 @@ 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({
|
||||||
@ -691,8 +592,6 @@ 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
|
||||||
)
|
)
|
||||||
@ -762,25 +661,17 @@ 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: actor,
|
author,
|
||||||
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)
|
||||||
@ -790,16 +681,9 @@ 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)
|
||||||
@ -822,8 +706,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
{
|
{
|
||||||
name: 'task-store',
|
name: 'task-store',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
// Persist user identity and UI state, not task data
|
// Only persist UI state, not data
|
||||||
currentUser: state.currentUser,
|
|
||||||
selectedProjectId: state.selectedProjectId,
|
selectedProjectId: state.selectedProjectId,
|
||||||
selectedTaskId: state.selectedTaskId,
|
selectedTaskId: state.selectedTaskId,
|
||||||
selectedSprintId: state.selectedSprintId,
|
selectedSprintId: state.selectedSprintId,
|
||||||
@ -835,7 +718,6 @@ 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