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