Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-20 13:43:41 -06:00
parent ed1d2d956a
commit a353ed0feb
12 changed files with 923 additions and 107 deletions

View File

@ -18,7 +18,8 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- Tasks use labels (`tags: string[]`) and can have multiple labels. - Tasks use labels (`tags: string[]`) and can have multiple labels.
- Tasks support attachments (`attachments: TaskAttachment[]`). - Tasks support attachments (`attachments: TaskAttachment[]`).
- Tasks now track `createdById`, `createdByName`, `updatedById`, and `updatedByName`. - Tasks now track `createdById`, `createdByName`, `createdByAvatarUrl`, `updatedById`, `updatedByName`, and `updatedByAvatarUrl`.
- Tasks now track assignment via `assigneeId`, `assigneeName`, `assigneeEmail`, and `assigneeAvatarUrl`.
- There is no active `backlog` status in workflow logic. - There is no active `backlog` status in workflow logic.
- A task is considered in Backlog when `sprintId` is empty. - A task is considered in Backlog when `sprintId` is empty.
- Current status values: - Current status values:
@ -45,12 +46,39 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- Main board (`/`) and task detail pages (`/tasks/{taskId}`) require an authenticated session. - 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. - 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). - 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. - Added `PATCH /api/auth/account` to update profile and password for the current user.
- Session cookie: `gantt_session` (HTTP-only, same-site lax, secure in production). - Session cookie: `gantt_session` (HTTP-only, same-site lax, secure in production).
- Added `Remember me` in auth forms: - Added `Remember me` in auth forms:
- Checked: persistent 30-day cookie/session. - Checked: persistent 30-day cookie/session.
- Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry. - Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry.
- Task/comment authorship now uses authenticated user identity. - Task/comment authorship now uses authenticated user identity.
- Added `GET /api/auth/users` so task forms can load assignable users.
- User records now include optional `avatarUrl` profile photos.
### Task assignment
- You can assign or unassign tasks in:
- New Task modal
- Task detail popup on board
- URL task detail page (`/tasks/{taskId}`)
- New tasks default to the current signed-in user as assignee.
- Kanban and Backlog cards show a visible assignee avatar/initial pill.
- When someone else updates status (for example closes a task), updater fields still track who made that change.
### Profile photos and account settings
- Account Settings (`/settings`) has separate save actions:
- `Save Profile` for name/email/photo
- `Update Password` for password-only changes
- Profile photos can be uploaded/removed in settings.
- Users without uploaded photos get a generated default avatar (unique by user seed).
- Settings also provides multiple preset avatar choices.
- Profile photos appear in:
- Board/task header identity chips
- Assignee pills on Kanban and Backlog
- Threaded comment author avatars
- Creator/updater identity rows on task detail views
- Avatar updates persist in SQLite and propagate through session/user APIs.
### Labels ### Labels

View File

@ -13,12 +13,14 @@ export async function PATCH(request: Request) {
const body = (await request.json()) as { const body = (await request.json()) as {
name?: string; name?: string;
email?: string; email?: string;
avatarUrl?: string | null;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
}; };
const nextName = typeof body.name === "string" ? body.name : undefined; const nextName = typeof body.name === "string" ? body.name : undefined;
const nextEmail = typeof body.email === "string" ? body.email : undefined; const nextEmail = typeof body.email === "string" ? body.email : undefined;
const nextAvatarUrl = body.avatarUrl === null || typeof body.avatarUrl === "string" ? body.avatarUrl : undefined;
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : undefined; const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : undefined;
const newPassword = typeof body.newPassword === "string" ? body.newPassword : undefined; const newPassword = typeof body.newPassword === "string" ? body.newPassword : undefined;
@ -26,6 +28,7 @@ export async function PATCH(request: Request) {
userId: sessionUser.id, userId: sessionUser.id,
name: nextName, name: nextName,
email: nextEmail, email: nextEmail,
avatarUrl: nextAvatarUrl,
currentPassword, currentPassword,
newPassword, newPassword,
}); });
@ -46,6 +49,7 @@ export async function PATCH(request: Request) {
message.includes("Current password is required") message.includes("Current password is required")
|| message.includes("at least") || message.includes("at least")
|| message.includes("Invalid email") || message.includes("Invalid email")
|| message.includes("Avatar")
) { ) {
return NextResponse.json({ error: message }, { status: 400 }); return NextResponse.json({ error: message }, { status: 400 });
} }

View File

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getAuthenticatedUser, listUsers } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function GET() {
try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ users: listUsers() });
} catch {
return NextResponse.json({ error: "Failed to load users" }, { status: 500 });
}
}

View File

@ -43,11 +43,14 @@ export async function POST(request: Request) {
if (task) { if (task) {
const existingIndex = data.tasks.findIndex((t) => t.id === task.id); const existingIndex = data.tasks.findIndex((t) => t.id === task.id);
if (existingIndex >= 0) { if (existingIndex >= 0) {
const existingTask = data.tasks[existingIndex];
data.tasks[existingIndex] = { data.tasks[existingIndex] = {
...existingTask,
...task, ...task,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
updatedById: user.id, updatedById: user.id,
updatedByName: user.name, updatedByName: user.name,
updatedByAvatarUrl: user.avatarUrl,
}; };
} else { } else {
data.tasks.push({ data.tasks.push({
@ -57,8 +60,14 @@ export async function POST(request: Request) {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
createdById: task.createdById || user.id, createdById: task.createdById || user.id,
createdByName: task.createdByName || user.name, createdByName: task.createdByName || user.name,
createdByAvatarUrl: task.createdByAvatarUrl || user.avatarUrl,
updatedById: user.id, updatedById: user.id,
updatedByName: user.name, updatedByName: user.name,
updatedByAvatarUrl: user.avatarUrl,
assigneeId: task.assigneeId || user.id,
assigneeName: task.assigneeName || user.name,
assigneeEmail: task.assigneeEmail || user.email,
assigneeAvatarUrl: task.assigneeAvatarUrl || user.avatarUrl,
}); });
} }
} }
@ -68,8 +77,14 @@ export async function POST(request: Request) {
...entry, ...entry,
createdById: entry.createdById || user.id, createdById: entry.createdById || user.id,
createdByName: entry.createdByName || user.name, createdByName: entry.createdByName || user.name,
createdByAvatarUrl: entry.createdByAvatarUrl || (entry.createdById === user.id ? user.avatarUrl : undefined),
updatedById: entry.updatedById || user.id, updatedById: entry.updatedById || user.id,
updatedByName: entry.updatedByName || user.name, updatedByName: entry.updatedByName || user.name,
updatedByAvatarUrl: entry.updatedByAvatarUrl || (entry.updatedById === user.id ? user.avatarUrl : undefined),
assigneeId: entry.assigneeId || undefined,
assigneeName: entry.assigneeName || undefined,
assigneeEmail: entry.assigneeEmail || undefined,
assigneeAvatarUrl: entry.assigneeAvatarUrl || undefined,
})); }));
} }

View File

@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { generateAvatarDataUrl } from "@/lib/avatar"
import { import {
blobFromDataUrl, blobFromDataUrl,
coerceDataUrlMimeType, coerceDataUrlMimeType,
@ -35,6 +36,13 @@ import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, typ
import { BacklogView } from "@/components/BacklogView" import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
interface AssignableUser {
id: string
name: string
email?: string
avatarUrl?: string
}
const typeColors: Record<TaskType, string> = { const typeColors: Record<TaskType, string> = {
idea: "bg-purple-500", idea: "bg-purple-500",
task: "bg-blue-500", task: "bg-blue-500",
@ -60,6 +68,30 @@ const priorityColors: Record<Priority, string> = {
const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"] const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"]
function AvatarCircle({
name,
avatarUrl,
seed,
sizeClass = "h-6 w-6",
title,
}: {
name?: string
avatarUrl?: string
seed?: string
sizeClass?: string
title?: string
}) {
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User")
return (
<img
src={displayUrl}
alt={name || "User avatar"}
className={`${sizeClass} rounded-full border border-slate-700 object-cover bg-slate-800`}
title={title || name || "User"}
/>
)
}
// Sprint board columns mapped to workflow statuses // Sprint board columns mapped to workflow statuses
const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [ const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [
{ {
@ -272,12 +304,20 @@ function KanbanTaskCard({
</span> </span>
)} )}
</div> </div>
{task.dueDate && ( <div className="flex items-center gap-2">
<span className="text-slate-500 flex items-center gap-1"> <AvatarCircle
<Calendar className="w-3 h-3" /> name={task.assigneeName || "Unassigned"}
{new Date(task.dueDate).toLocaleDateString()} avatarUrl={task.assigneeAvatarUrl}
</span> seed={task.assigneeId}
)} title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"}
/>
{task.dueDate && (
<span className="text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
</div> </div>
{taskTags.length > 0 && ( {taskTags.length > 0 && (
@ -335,6 +375,7 @@ export default function Home() {
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null) const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null) const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [users, setUsers] = useState<AssignableUser[]>([])
const getTags = (taskLike: { tags?: unknown }) => { const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[] if (!Array.isArray(taskLike.tags)) return [] as string[]
@ -372,6 +413,7 @@ export default function Home() {
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user", 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", name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined, email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined,
type, type,
} }
} }
@ -403,6 +445,21 @@ export default function Home() {
}, [tasks]) }, [tasks])
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage]) const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
const assignableUsers = useMemo(() => {
const byId = new Map<string, AssignableUser>()
users.forEach((user) => {
if (user.id) byId.set(user.id, user)
})
if (currentUser.id) {
byId.set(currentUser.id, {
id: currentUser.id,
name: currentUser.name,
email: currentUser.email,
avatarUrl: currentUser.avatarUrl,
})
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}, [users, currentUser])
useEffect(() => { useEffect(() => {
let isMounted = true let isMounted = true
@ -420,6 +477,7 @@ export default function Home() {
id: data.user.id, id: data.user.id,
name: data.user.name, name: data.user.name,
email: data.user.email, email: data.user.email,
avatarUrl: data.user.avatarUrl,
}) })
setAuthReady(true) setAuthReady(true)
} catch { } catch {
@ -438,6 +496,49 @@ export default function Home() {
syncFromServer() syncFromServer()
}, [authReady, syncFromServer]) }, [authReady, syncFromServer])
useEffect(() => {
if (!authReady) return
let isMounted = true
const loadUsers = async () => {
try {
const res = await fetch("/api/auth/users", { cache: "no-store" })
if (!res.ok) return
const data = await res.json()
if (!isMounted) return
const nextUsers = Array.isArray(data.users) ? (data.users as Array<Partial<AssignableUser>>) : []
setUsers(
nextUsers
.filter((entry): entry is Partial<AssignableUser> & { id: string; name: string } =>
!!entry && typeof entry.id === "string" && typeof entry.name === "string"
)
.map((entry) => ({ id: entry.id, name: entry.name, email: entry.email, avatarUrl: entry.avatarUrl }))
)
} catch {
// ignore
}
}
loadUsers()
return () => {
isMounted = false
}
}, [authReady])
useEffect(() => {
if (!authReady) return
setNewTask((prev) => {
if (prev.assigneeId) return prev
return {
...prev,
assigneeId: currentUser.id,
assigneeName: currentUser.name,
assigneeEmail: currentUser.email,
assigneeAvatarUrl: currentUser.avatarUrl,
}
})
}, [authReady, currentUser.id, currentUser.name, currentUser.email, currentUser.avatarUrl])
useEffect(() => { useEffect(() => {
if (selectedTaskId) { if (selectedTaskId) {
selectTask(null) selectTask(null)
@ -573,6 +674,56 @@ export default function Home() {
setDragOverKanbanColumnKey(null) setDragOverKanbanColumnKey(null)
} }
const resolveAssignee = (assigneeId: string | undefined) => {
if (!assigneeId) return null
return assignableUsers.find((user) => user.id === assigneeId) || null
}
const setNewTaskAssignee = (assigneeId: string) => {
if (!assigneeId) {
setNewTask((prev) => ({
...prev,
assigneeId: undefined,
assigneeName: undefined,
assigneeEmail: undefined,
assigneeAvatarUrl: undefined,
}))
return
}
const assignee = resolveAssignee(assigneeId)
setNewTask((prev) => ({
...prev,
assigneeId,
assigneeName: assignee?.name || prev.assigneeName,
assigneeEmail: assignee?.email,
assigneeAvatarUrl: assignee?.avatarUrl,
}))
}
const setEditedTaskAssignee = (assigneeId: string) => {
if (!editedTask) return
if (!assigneeId) {
setEditedTask({
...editedTask,
assigneeId: undefined,
assigneeName: undefined,
assigneeEmail: undefined,
assigneeAvatarUrl: undefined,
})
return
}
const assignee = resolveAssignee(assigneeId)
setEditedTask({
...editedTask,
assigneeId,
assigneeName: assignee?.name || editedTask.assigneeName,
assigneeEmail: assignee?.email,
assigneeAvatarUrl: assignee?.avatarUrl,
})
}
const handleAddTask = () => { const handleAddTask = () => {
if (newTask.title?.trim()) { if (newTask.title?.trim()) {
// If a specific sprint is selected, use that sprint's project // If a specific sprint is selected, use that sprint's project
@ -588,10 +739,26 @@ export default function Home() {
tags: newTask.tags || [], tags: newTask.tags || [],
projectId: targetProjectId, projectId: targetProjectId,
sprintId: newTask.sprintId || currentSprint?.id, sprintId: newTask.sprintId || currentSprint?.id,
assigneeId: newTask.assigneeId,
assigneeName: newTask.assigneeName,
assigneeEmail: newTask.assigneeEmail,
assigneeAvatarUrl: newTask.assigneeAvatarUrl,
} }
addTask(taskToCreate) addTask(taskToCreate)
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], sprintId: undefined }) setNewTask({
title: "",
description: "",
type: "task",
priority: "medium",
status: "open",
tags: [],
sprintId: undefined,
assigneeId: currentUser.id,
assigneeName: currentUser.name,
assigneeEmail: currentUser.email,
assigneeAvatarUrl: currentUser.avatarUrl,
})
setNewTaskLabelInput("") setNewTaskLabelInput("")
setNewTaskOpen(false) setNewTaskOpen(false)
} }
@ -716,9 +883,10 @@ export default function Home() {
<span className="hidden md:inline text-sm text-slate-400"> <span className="hidden md:inline text-sm text-slate-400">
{tasks.length} tasks · {allLabels.length} labels {tasks.length} tasks · {allLabels.length} labels
</span> </span>
<span className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300"> <div className="flex items-center gap-2 rounded border border-slate-700 px-2 py-1">
{currentUser.name} <AvatarCircle name={currentUser.name} avatarUrl={currentUser.avatarUrl} seed={currentUser.id} sizeClass="h-5 w-5" />
</span> <span className="text-xs text-slate-300">{currentUser.name}</span>
</div>
<button <button
type="button" type="button"
onClick={() => router.push("/settings")} onClick={() => router.push("/settings")}
@ -943,6 +1111,21 @@ export default function Home() {
</select> </select>
</div> </div>
</div> </div>
<div>
<Label>Assignee</Label>
<select
value={newTask.assigneeId || ""}
onChange={(e) => setNewTaskAssignee(e.target.value)}
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="">Unassigned</option>
{assignableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div> <div>
<Label>Labels</Label> <Label>Labels</Label>
<div className="mt-1.5 space-y-2"> <div className="mt-1.5 space-y-2">
@ -1056,6 +1239,24 @@ export default function Home() {
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })} onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white" className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
/> />
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span className="inline-flex items-center gap-2">
<AvatarCircle
name={editedTask.createdByName || "Unknown"}
avatarUrl={editedTask.createdByAvatarUrl || resolveAssignee(editedTask.createdById)?.avatarUrl}
seed={editedTask.createdById}
/>
Created by {editedTask.createdByName || "Unknown"}
</span>
<span className="inline-flex items-center gap-2">
<AvatarCircle
name={editedTask.updatedByName || "Unknown"}
avatarUrl={editedTask.updatedByAvatarUrl || resolveAssignee(editedTask.updatedById)?.avatarUrl}
seed={editedTask.updatedById}
/>
Last updated by {editedTask.updatedByName || "Unknown"}
</span>
</div>
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
@ -1127,6 +1328,23 @@ export default function Home() {
</select> </select>
</div> </div>
{/* Assignee */}
<div>
<Label className="text-slate-400">Assignee</Label>
<select
value={editedTask.assigneeId || ""}
onChange={(e) => setEditedTaskAssignee(e.target.value)}
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="">Unassigned</option>
{assignableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
{/* Labels */} {/* Labels */}
<div> <div>
<Label className="text-slate-400">Labels</Label> <Label className="text-slate-400">Labels</Label>
@ -1296,6 +1514,7 @@ export default function Home() {
const author = getCommentAuthor(comment.author) const author = getCommentAuthor(comment.author)
const isAssistant = author.type === "assistant" const isAssistant = author.type === "assistant"
const displayName = author.id === currentUser.id ? "You" : author.name const displayName = author.id === currentUser.id ? "You" : author.name
const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl
return ( return (
<div <div
key={comment.id} key={comment.id}
@ -1303,15 +1522,13 @@ export default function Home() {
isAssistant ? "bg-blue-900/20" : "bg-slate-800/50" isAssistant ? "bg-blue-900/20" : "bg-slate-800/50"
}`} }`}
> >
<div {isAssistant ? (
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${ <div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium bg-blue-600 text-white">
isAssistant AI
? "bg-blue-600 text-white" </div>
: "bg-slate-700 text-slate-300" ) : (
}`} <AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={author.id} sizeClass="h-8 w-8" />
> )}
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-300"> <span className="text-sm font-medium text-slate-300">

View File

@ -1,23 +1,39 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState, type ChangeEvent } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useTaskStore } from "@/stores/useTaskStore" import { useTaskStore } from "@/stores/useTaskStore"
import { buildAvatarPresets, generateAvatarDataUrl, getInitials } from "@/lib/avatar"
const readImageFileAsDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ""))
reader.onerror = () => reject(new Error("Failed to read image"))
reader.readAsDataURL(file)
})
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter() const router = useRouter()
const { setCurrentUser } = useTaskStore() const { setCurrentUser } = useTaskStore()
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSavingProfile, setIsSavingProfile] = useState(false)
const [isSavingPassword, setIsSavingPassword] = useState(false)
const [isLoggingOut, setIsLoggingOut] = useState(false) const [isLoggingOut, setIsLoggingOut] = useState(false)
const [error, setError] = useState<string | null>(null) const [profileError, setProfileError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [profileSuccess, setProfileSuccess] = useState<string | null>(null)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null)
const [name, setName] = useState("") const [name, setName] = useState("")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [profileAvatarUrl, setProfileAvatarUrl] = useState("")
const [currentUserId, setCurrentUserId] = useState("")
const [initialEmail, setInitialEmail] = useState("")
const [profileCurrentPassword, setProfileCurrentPassword] = useState("")
const [currentPassword, setCurrentPassword] = useState("") const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("") const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("")
@ -36,10 +52,14 @@ export default function SettingsPage() {
setName(data.user.name || "") setName(data.user.name || "")
setEmail(data.user.email || "") setEmail(data.user.email || "")
setProfileAvatarUrl(data.user.avatarUrl || "")
setCurrentUserId(data.user.id || "")
setInitialEmail(data.user.email || "")
setCurrentUser({ setCurrentUser({
id: data.user.id, id: data.user.id,
name: data.user.name, name: data.user.name,
email: data.user.email, email: data.user.email,
avatarUrl: data.user.avatarUrl,
}) })
setAuthReady(true) setAuthReady(true)
} catch { } catch {
@ -53,41 +73,37 @@ export default function SettingsPage() {
} }
}, [router, setCurrentUser]) }, [router, setCurrentUser])
const handleSave = async () => { const handleProfileSave = async () => {
setError(null) setProfileError(null)
setSuccess(null) setProfileSuccess(null)
const trimmedName = name.trim() const trimmedName = name.trim()
const trimmedEmail = email.trim() const trimmedEmail = email.trim()
if (!trimmedName || !trimmedEmail) { if (!trimmedName || !trimmedEmail) {
setError("Name and email are required") setProfileError("Name and email are required")
return return
} }
if (newPassword || confirmPassword || currentPassword) { const emailChanged = trimmedEmail.toLowerCase() !== initialEmail.toLowerCase()
if (!currentPassword) { if (emailChanged && !profileCurrentPassword) {
setError("Current password is required to change password") setProfileError("Current password is required to change email")
return return
}
if (!newPassword) {
setError("New password is required")
return
}
if (newPassword !== confirmPassword) {
setError("New password and confirmation do not match")
return
}
} }
setIsSaving(true) setIsSavingProfile(true)
try { try {
const payload: Record<string, string> = { const payload: {
name: string
email: string
avatarUrl: string | null
currentPassword?: string
} = {
name: trimmedName, name: trimmedName,
email: trimmedEmail, email: trimmedEmail,
avatarUrl: profileAvatarUrl || null,
} }
if (currentPassword) payload.currentPassword = currentPassword if (emailChanged) payload.currentPassword = profileCurrentPassword
if (newPassword) payload.newPassword = newPassword
const res = await fetch("/api/auth/account", { const res = await fetch("/api/auth/account", {
method: "PATCH", method: "PATCH",
@ -97,7 +113,7 @@ export default function SettingsPage() {
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
setError(data.error || "Failed to update account") setProfileError(data.error || "Failed to update profile")
return return
} }
@ -105,17 +121,93 @@ export default function SettingsPage() {
id: data.user.id, id: data.user.id,
name: data.user.name, name: data.user.name,
email: data.user.email, email: data.user.email,
avatarUrl: data.user.avatarUrl,
}) })
setName(data.user.name) setName(data.user.name)
setEmail(data.user.email) setEmail(data.user.email)
setProfileAvatarUrl(data.user.avatarUrl || "")
setCurrentUserId(data.user.id)
setInitialEmail(data.user.email)
setProfileCurrentPassword("")
setProfileSuccess("Profile updated")
} catch {
setProfileError("Failed to update profile")
} finally {
setIsSavingProfile(false)
}
}
const handleAvatarUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file) return
if (!file.type.startsWith("image/")) {
setProfileError("Please choose an image file")
return
}
try {
const dataUrl = await readImageFileAsDataUrl(file)
setProfileAvatarUrl(dataUrl)
setProfileError(null)
setProfileSuccess(null)
} catch {
setProfileError("Failed to load image")
}
}
const avatarSeed = currentUserId || email || name || "user"
const generatedAvatarUrl = useMemo(
() => generateAvatarDataUrl(avatarSeed, name || email || "User"),
[avatarSeed, name, email]
)
const avatarPresets = useMemo(
() => buildAvatarPresets(avatarSeed, name || email || "User", 8),
[avatarSeed, name, email]
)
const profilePreviewAvatarUrl = profileAvatarUrl || generatedAvatarUrl
const handlePasswordSave = async () => {
setPasswordError(null)
setPasswordSuccess(null)
if (!currentPassword) {
setPasswordError("Current password is required")
return
}
if (!newPassword) {
setPasswordError("New password is required")
return
}
if (newPassword !== confirmPassword) {
setPasswordError("New password and confirmation do not match")
return
}
setIsSavingPassword(true)
try {
const res = await fetch("/api/auth/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
}),
})
const data = await res.json()
if (!res.ok) {
setPasswordError(data.error || "Failed to update password")
return
}
setCurrentPassword("") setCurrentPassword("")
setNewPassword("") setNewPassword("")
setConfirmPassword("") setConfirmPassword("")
setSuccess("Account updated") setPasswordSuccess("Password updated")
} catch { } catch {
setError("Failed to update account") setPasswordError("Failed to update password")
} finally { } finally {
setIsSaving(false) setIsSavingPassword(false)
} }
} }
@ -162,6 +254,64 @@ export default function SettingsPage() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div>
<label className="block text-sm text-slate-300 mb-2">Profile Photo</label>
<div className="flex items-center gap-3">
<img
src={profilePreviewAvatarUrl}
alt={name || "Profile photo"}
className="h-14 w-14 rounded-full border border-slate-700 object-cover bg-slate-800"
/>
<div className="flex items-center gap-2">
<label
htmlFor="profile-avatar-upload"
className="px-3 py-2 rounded-md border border-slate-700 bg-slate-800 text-sm text-slate-200 hover:border-slate-500 cursor-pointer"
>
Upload
</label>
<input
id="profile-avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
{profileAvatarUrl && (
<Button
type="button"
variant="ghost"
onClick={() => setProfileAvatarUrl("")}
>
Remove
</Button>
)}
</div>
</div>
<p className="text-xs text-slate-500 mt-2">
{profileAvatarUrl ? "Using custom avatar." : `Using generated default avatar (${getInitials(name || email || "User")}).`}
</p>
<div className="mt-3">
<p className="text-xs text-slate-400 mb-2">Or pick a preset:</p>
<div className="flex flex-wrap gap-2">
{avatarPresets.map((presetUrl, index) => (
<button
key={presetUrl}
type="button"
onClick={() => setProfileAvatarUrl(presetUrl)}
className={`rounded-full p-0.5 border ${profileAvatarUrl === presetUrl ? "border-blue-400" : "border-slate-700 hover:border-slate-500"}`}
title={`Preset ${index + 1}`}
>
<img
src={presetUrl}
alt={`Preset avatar ${index + 1}`}
className="h-8 w-8 rounded-full object-cover"
/>
</button>
))}
</div>
</div>
</div>
<div> <div>
<label className="block text-sm text-slate-300 mb-1">Name</label> <label className="block text-sm text-slate-300 mb-1">Name</label>
<input <input
@ -183,12 +333,32 @@ export default function SettingsPage() {
placeholder="you@example.com" placeholder="you@example.com"
/> />
</div> </div>
<div>
<label className="block text-sm text-slate-300 mb-1">Current Password (only if changing email)</label>
<input
type="password"
value={profileCurrentPassword}
onChange={(event) => setProfileCurrentPassword(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>
{profileError && <p className="text-sm text-red-400">{profileError}</p>}
{profileSuccess && <p className="text-sm text-emerald-400">{profileSuccess}</p>}
<div className="pt-2">
<Button onClick={handleProfileSave} disabled={isSavingProfile}>
{isSavingProfile ? "Saving..." : "Save Profile"}
</Button>
</div> </div>
<div className="border-t border-slate-800 pt-4 space-y-4"> <div className="border-t border-slate-800 pt-4 space-y-4">
<div> <div>
<h2 className="text-sm font-medium text-slate-200">Change Password</h2> <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> <p className="text-xs text-slate-500 mt-1">Update password separately from profile settings.</p>
</div> </div>
<div> <div>
@ -225,12 +395,12 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{error && <p className="text-sm text-red-400">{error}</p>} {passwordError && <p className="text-sm text-red-400">{passwordError}</p>}
{success && <p className="text-sm text-emerald-400">{success}</p>} {passwordSuccess && <p className="text-sm text-emerald-400">{passwordSuccess}</p>}
<div className="pt-2"> <div className="pt-2">
<Button onClick={handleSave} disabled={isSaving}> <Button onClick={handlePasswordSave} disabled={isSavingPassword}>
{isSaving ? "Saving..." : "Save Changes"} {isSavingPassword ? "Saving..." : "Update Password"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { generateAvatarDataUrl } from "@/lib/avatar"
import { import {
blobFromDataUrl, blobFromDataUrl,
coerceDataUrlMimeType, coerceDataUrlMimeType,
@ -28,6 +29,13 @@ import {
type UserProfile, type UserProfile,
} from "@/stores/useTaskStore" } from "@/stores/useTaskStore"
interface AssignableUser {
id: string
name: string
email?: string
avatarUrl?: string
}
const typeColors: Record<TaskType, string> = { const typeColors: Record<TaskType, string> = {
idea: "bg-purple-500", idea: "bg-purple-500",
task: "bg-blue-500", task: "bg-blue-500",
@ -110,6 +118,7 @@ const getCommentAuthor = (value: unknown): CommentAuthor => {
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user", 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", name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined, email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined,
type, type,
} }
} }
@ -118,6 +127,7 @@ const profileToAuthor = (profile: UserProfile): CommentAuthor => ({
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
email: profile.email, email: profile.email,
avatarUrl: profile.avatarUrl,
type: "human", type: "human",
}) })
@ -175,6 +185,30 @@ const formatBytes = (bytes: number) => {
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}` return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
} }
function AvatarCircle({
name,
avatarUrl,
seed,
sizeClass = "h-7 w-7",
title,
}: {
name?: string
avatarUrl?: string
seed?: string
sizeClass?: string
title?: string
}) {
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User")
return (
<img
src={displayUrl}
alt={name || "User avatar"}
className={`${sizeClass} rounded-full border border-slate-700 object-cover bg-slate-800`}
title={title || name || "User"}
/>
)
}
const readFileAsDataUrl = (file: File) => const readFileAsDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => { new Promise<string>((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
@ -208,6 +242,7 @@ export default function TaskDetailPage() {
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({}) const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({}) const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [users, setUsers] = useState<AssignableUser[]>([])
useEffect(() => { useEffect(() => {
let isMounted = true let isMounted = true
@ -224,6 +259,7 @@ export default function TaskDetailPage() {
id: data.user.id, id: data.user.id,
name: data.user.name, name: data.user.name,
email: data.user.email, email: data.user.email,
avatarUrl: data.user.avatarUrl,
}) })
setAuthReady(true) setAuthReady(true)
} catch { } catch {
@ -242,6 +278,35 @@ export default function TaskDetailPage() {
syncFromServer() syncFromServer()
}, [authReady, syncFromServer]) }, [authReady, syncFromServer])
useEffect(() => {
if (!authReady) return
let isMounted = true
const loadUsers = async () => {
try {
const res = await fetch("/api/auth/users", { cache: "no-store" })
if (!res.ok) return
const data = await res.json()
if (!isMounted) return
const nextUsers = Array.isArray(data.users) ? (data.users as Array<Partial<AssignableUser>>) : []
setUsers(
nextUsers
.filter((entry): entry is Partial<AssignableUser> & { id: string; name: string } =>
!!entry && typeof entry.id === "string" && typeof entry.name === "string"
)
.map((entry) => ({ id: entry.id, name: entry.name, email: entry.email, avatarUrl: entry.avatarUrl }))
)
} catch {
// ignore
}
}
loadUsers()
return () => {
isMounted = false
}
}, [authReady])
useEffect(() => { useEffect(() => {
if (selectedTask) { if (selectedTask) {
setEditedTask({ setEditedTask({
@ -268,6 +333,22 @@ export default function TaskDetailPage() {
return Array.from(labels.keys()) return Array.from(labels.keys())
}, [tasks]) }, [tasks])
const assignableUsers = useMemo(() => {
const byId = new Map<string, AssignableUser>()
users.forEach((user) => {
if (user.id) byId.set(user.id, user)
})
if (currentUser.id) {
byId.set(currentUser.id, {
id: currentUser.id,
name: currentUser.name,
email: currentUser.email,
avatarUrl: currentUser.avatarUrl,
})
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}, [users, currentUser])
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => { const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []) const files = Array.from(event.target.files || [])
if (files.length === 0) return if (files.length === 0) return
@ -339,6 +420,34 @@ export default function TaskDetailPage() {
}) })
} }
const resolveAssignee = (assigneeId: string | undefined) => {
if (!assigneeId) return null
return assignableUsers.find((user) => user.id === assigneeId) || null
}
const setEditedTaskAssignee = (assigneeId: string) => {
if (!editedTask) return
if (!assigneeId) {
setEditedTask({
...editedTask,
assigneeId: undefined,
assigneeName: undefined,
assigneeEmail: undefined,
assigneeAvatarUrl: undefined,
})
return
}
const assignee = resolveAssignee(assigneeId)
setEditedTask({
...editedTask,
assigneeId,
assigneeName: assignee?.name || editedTask.assigneeName,
assigneeEmail: assignee?.email,
assigneeAvatarUrl: assignee?.avatarUrl,
})
}
const handleSave = () => { const handleSave = () => {
if (!editedTask) return if (!editedTask) return
setIsSaving(true) setIsSaving(true)
@ -397,7 +506,7 @@ export default function TaskDetailPage() {
} }
} }
const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] => const renderThread = (comments: TaskComment[], depth = 0) =>
comments.map((comment) => { comments.map((comment) => {
const replies = getComments(comment.replies) const replies = getComments(comment.replies)
const isReplying = !!openReplyEditors[comment.id] const isReplying = !!openReplyEditors[comment.id]
@ -405,15 +514,20 @@ export default function TaskDetailPage() {
const author = getCommentAuthor(comment.author) const author = getCommentAuthor(comment.author)
const isAssistant = author.type === "assistant" const isAssistant = author.type === "assistant"
const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name
const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl
return ( return (
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}> <div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
<div className={`p-3 rounded-lg border ${isAssistant ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}> <div className={`p-3 rounded-lg border ${isAssistant ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${isAssistant ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}> {isAssistant ? (
{isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()} <span className="w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium bg-blue-600 text-white">
</span> AI
</span>
) : (
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={author.id} />
)}
<span className="text-sm text-slate-300 font-medium">{displayName}</span> <span className="text-sm text-slate-300 font-medium">{displayName}</span>
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span> <span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
</div> </div>
@ -521,7 +635,10 @@ export default function TaskDetailPage() {
onClick={handleLogout} onClick={handleLogout}
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500" className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
> >
Logout {currentUser.name} <span className="inline-flex items-center gap-2">
<AvatarCircle name={currentUser.name} avatarUrl={currentUser.avatarUrl} seed={currentUser.id} sizeClass="h-5 w-5" />
Logout {currentUser.name}
</span>
</button> </button>
</div> </div>
</div> </div>
@ -542,9 +659,38 @@ export default function TaskDetailPage() {
onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })} onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })}
className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white" className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
/> />
<p className="text-xs text-slate-500 mt-2"> <div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-400">
Created by {editedTask.createdByName || "Unknown"}{editedTask.updatedByName ? ` · Last updated by ${editedTask.updatedByName}` : ""} <span className="inline-flex items-center gap-2">
</p> <AvatarCircle
name={editedTask.createdByName || "Unknown"}
avatarUrl={editedTask.createdByAvatarUrl || resolveAssignee(editedTask.createdById)?.avatarUrl}
seed={editedTask.createdById}
sizeClass="h-6 w-6"
/>
Created by {editedTask.createdByName || "Unknown"}
</span>
<span className="inline-flex items-center gap-2">
<AvatarCircle
name={editedTask.updatedByName || "Unknown"}
avatarUrl={editedTask.updatedByAvatarUrl || resolveAssignee(editedTask.updatedById)?.avatarUrl}
seed={editedTask.updatedById}
sizeClass="h-6 w-6"
/>
Last updated by {editedTask.updatedByName || "Unknown"}
</span>
</div>
<div className="mt-2">
<span className="text-xs text-slate-500 mr-2">Assignee</span>
<span className="inline-flex items-center gap-2 text-xs text-slate-300">
<AvatarCircle
name={editedTask.assigneeName || "Unassigned"}
avatarUrl={editedTask.assigneeAvatarUrl}
seed={editedTask.assigneeId}
sizeClass="h-6 w-6"
/>
{editedTask.assigneeName || "Unassigned"}
</span>
</div>
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
<div> <div>
@ -611,6 +757,22 @@ export default function TaskDetailPage() {
</select> </select>
</div> </div>
<div>
<Label className="text-slate-400">Assignee</Label>
<select
value={editedTask.assigneeId || ""}
onChange={(event) => setEditedTaskAssignee(event.target.value)}
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="">Unassigned</option>
{assignableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div> <div>
<Label className="text-slate-400">Labels</Label> <Label className="text-slate-400">Labels</Label>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">

View File

@ -25,6 +25,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react" import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
import { format, isValid, parseISO } from "date-fns" import { format, isValid, parseISO } from "date-fns"
import { generateAvatarDataUrl } from "@/lib/avatar"
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
low: "bg-slate-600", low: "bg-slate-600",
@ -41,6 +42,18 @@ const typeLabels: Record<string, string> = {
plan: "📐", plan: "📐",
} }
function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) {
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned")
return (
<img
src={displayUrl}
alt={name || "Assignee"}
className="h-6 w-6 rounded-full border border-slate-700 object-cover bg-slate-900"
title={name ? `Assigned to ${name}` : "Unassigned"}
/>
)
}
// Sortable Task Row // Sortable Task Row
function SortableTaskRow({ function SortableTaskRow({
task, task,
@ -90,6 +103,7 @@ function SortableTaskRow({
{task.comments && task.comments.length > 0 && ( {task.comments && task.comments.length > 0 && (
<span className="text-xs text-slate-500">💬 {task.comments.length}</span> <span className="text-xs text-slate-500">💬 {task.comments.length}</span>
)} )}
<AssigneeAvatar name={task.assigneeName} avatarUrl={task.assigneeAvatarUrl} seed={task.assigneeId} />
</div> </div>
) )
} }
@ -106,6 +120,7 @@ function DragOverlayItem({ task }: { task: Task }) {
<Badge className={`${priorityColors[task.priority]} text-white text-xs`}> <Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
{task.priority} {task.priority}
</Badge> </Badge>
<AssigneeAvatar name={task.assigneeName} avatarUrl={task.assigneeAvatarUrl} seed={task.assigneeId} />
</div> </div>
) )
} }

58
src/lib/avatar.ts Normal file
View File

@ -0,0 +1,58 @@
const AVATAR_PALETTES = [
["#1f2937", "#3b82f6"],
["#0f172a", "#22c55e"],
["#172554", "#06b6d4"],
["#3f1d2e", "#ec4899"],
["#1f2937", "#f59e0b"],
["#111827", "#a855f7"],
["#052e16", "#14b8a6"],
["#3f3f46", "#e11d48"],
] as const;
export function getInitials(name?: string): string {
return (name || "User")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || "")
.join("") || "U";
}
function hashSeed(seed: string): number {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return hash;
}
export function generateAvatarDataUrl(seed: string, name?: string, paletteOffset = 0): string {
const safeSeed = seed || "default";
const hash = hashSeed(safeSeed);
const paletteIndex = (hash + paletteOffset) % AVATAR_PALETTES.length;
const palette = AVATAR_PALETTES[paletteIndex];
const initials = getInitials(name);
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${palette[0]}"/>
<stop offset="100%" stop-color="${palette[1]}"/>
</linearGradient>
</defs>
<rect width="128" height="128" fill="url(#g)"/>
<circle cx="64" cy="64" r="52" fill="rgba(255,255,255,0.12)"/>
<text x="64" y="78" text-anchor="middle" font-family="Arial, sans-serif" font-size="44" font-weight="700" fill="white">${initials}</text>
</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
export function buildAvatarPresets(seed: string, name?: string, count = 6): string[] {
const urls: string[] = [];
for (let i = 0; i < count; i += 1) {
urls.push(generateAvatarDataUrl(`${seed}:${i}`, name, i));
}
return urls;
}

View File

@ -18,6 +18,7 @@ export interface AuthUser {
id: string; id: string;
name: string; name: string;
email: string; email: string;
avatarUrl?: string;
createdAt: string; createdAt: string;
} }
@ -29,8 +30,31 @@ function normalizeEmail(email: string): string {
return email.trim().toLowerCase(); return email.trim().toLowerCase();
} }
function normalizeAvatarDataUrl(value: string | null | undefined): string | undefined {
if (value == null) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (!trimmed.startsWith("data:image/")) {
throw new Error("Avatar must be an image");
}
if (trimmed.length > 2_000_000) {
throw new Error("Avatar image is too large");
}
return trimmed;
}
function ensureUserSchema(database: SqliteDb) {
const userColumns = database.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
if (!userColumns.some((column) => column.name === "avatarUrl")) {
database.exec("ALTER TABLE users ADD COLUMN avatarUrl TEXT;");
}
}
function getDb(): SqliteDb { function getDb(): SqliteDb {
if (db) return db; if (db) {
ensureUserSchema(db);
return db;
}
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
const database = new Database(DB_FILE); const database = new Database(DB_FILE);
@ -40,6 +64,7 @@ function getDb(): SqliteDb {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
avatarUrl TEXT,
passwordHash TEXT NOT NULL, passwordHash TEXT NOT NULL,
createdAt TEXT NOT NULL createdAt TEXT NOT NULL
); );
@ -57,6 +82,8 @@ function getDb(): SqliteDb {
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt);
`); `);
ensureUserSchema(database);
db = database; db = database;
return database; return database;
} }
@ -115,12 +142,13 @@ export function registerUser(params: {
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name, name,
email, email,
avatarUrl: undefined,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
database database
.prepare("INSERT INTO users (id, name, email, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)") .prepare("INSERT INTO users (id, name, email, avatarUrl, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?, ?)")
.run(user.id, user.name, user.email, hashPassword(password), user.createdAt); .run(user.id, user.name, user.email, user.avatarUrl ?? null, hashPassword(password), user.createdAt);
return user; return user;
} }
@ -133,7 +161,7 @@ export function authenticateUser(params: {
deleteExpiredSessions(database); deleteExpiredSessions(database);
const email = normalizeEmail(params.email); const email = normalizeEmail(params.email);
const row = database const row = database
.prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1") .prepare("SELECT id, name, email, avatarUrl, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1")
.get(email) as UserRow | undefined; .get(email) as UserRow | undefined;
if (!row) return null; if (!row) return null;
if (!verifyPassword(params.password, row.passwordHash)) return null; if (!verifyPassword(params.password, row.passwordHash)) return null;
@ -142,6 +170,7 @@ export function authenticateUser(params: {
id: row.id, id: row.id,
name: row.name, name: row.name,
email: row.email, email: row.email,
avatarUrl: row.avatarUrl ?? undefined,
createdAt: row.createdAt, createdAt: row.createdAt,
}; };
} }
@ -150,6 +179,7 @@ export function updateUserAccount(params: {
userId: string; userId: string;
name?: string; name?: string;
email?: string; email?: string;
avatarUrl?: string | null;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
}): AuthUser { }): AuthUser {
@ -157,13 +187,15 @@ export function updateUserAccount(params: {
deleteExpiredSessions(database); deleteExpiredSessions(database);
const row = database const row = database
.prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1") .prepare("SELECT id, name, email, avatarUrl, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1")
.get(params.userId) as UserRow | undefined; .get(params.userId) as UserRow | undefined;
if (!row) throw new Error("User not found"); if (!row) throw new Error("User not found");
const requestedName = typeof params.name === "string" ? params.name.trim() : row.name; const requestedName = typeof params.name === "string" ? params.name.trim() : row.name;
const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email; const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email;
const hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl");
const requestedAvatar = hasAvatarInput ? normalizeAvatarDataUrl(params.avatarUrl) : row.avatarUrl;
const currentPassword = params.currentPassword || ""; const currentPassword = params.currentPassword || "";
const newPassword = params.newPassword || ""; const newPassword = params.newPassword || "";
@ -192,17 +224,25 @@ export function updateUserAccount(params: {
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash; const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash;
database database
.prepare("UPDATE users SET name = ?, email = ?, passwordHash = ? WHERE id = ?") .prepare("UPDATE users SET name = ?, email = ?, avatarUrl = ?, passwordHash = ? WHERE id = ?")
.run(requestedName, requestedEmail, nextPasswordHash, row.id); .run(requestedName, requestedEmail, requestedAvatar ?? null, nextPasswordHash, row.id);
return { return {
id: row.id, id: row.id,
name: requestedName, name: requestedName,
email: requestedEmail, email: requestedEmail,
avatarUrl: requestedAvatar ?? undefined,
createdAt: row.createdAt, createdAt: row.createdAt,
}; };
} }
export function listUsers(): AuthUser[] {
const database = getDb();
return database
.prepare("SELECT id, name, email, avatarUrl, createdAt FROM users ORDER BY LOWER(name) ASC")
.all() as AuthUser[];
}
export function createUserSession(userId: string, rememberMe: boolean): { export function createUserSession(userId: string, rememberMe: boolean): {
token: string; token: string;
expiresAt: string; expiresAt: string;
@ -240,7 +280,7 @@ export function getUserBySessionToken(token: string): AuthUser | null {
const now = new Date().toISOString(); const now = new Date().toISOString();
const row = database const row = database
.prepare(` .prepare(`
SELECT u.id, u.name, u.email, u.createdAt SELECT u.id, u.name, u.email, u.avatarUrl, u.createdAt
FROM sessions s FROM sessions s
JOIN users u ON u.id = s.userId JOIN users u ON u.id = s.userId
WHERE s.tokenHash = ? AND s.expiresAt > ? WHERE s.tokenHash = ? AND s.expiresAt > ?

View File

@ -23,6 +23,7 @@ export interface TaskCommentAuthor {
id: string; id: string;
name: string; name: string;
email?: string; email?: string;
avatarUrl?: string;
type: "human" | "assistant"; type: "human" | "assistant";
} }
@ -39,8 +40,14 @@ export interface Task {
updatedAt: string; updatedAt: string;
createdById?: string; createdById?: string;
createdByName?: string; createdByName?: string;
createdByAvatarUrl?: string;
updatedById?: string; updatedById?: string;
updatedByName?: string; updatedByName?: string;
updatedByAvatarUrl?: string;
assigneeId?: string;
assigneeName?: string;
assigneeEmail?: string;
assigneeAvatarUrl?: string;
dueDate?: string; dueDate?: string;
comments: TaskComment[]; comments: TaskComment[];
tags: string[]; tags: string[];
@ -91,6 +98,43 @@ type SqliteDb = InstanceType<typeof Database>;
let db: SqliteDb | null = null; let db: SqliteDb | null = null;
function ensureTaskSchema(database: SqliteDb) {
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
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 === "createdByAvatarUrl")) {
database.exec("ALTER TABLE tasks ADD COLUMN createdByAvatarUrl 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;");
}
if (!taskColumns.some((column) => column.name === "updatedByAvatarUrl")) {
database.exec("ALTER TABLE tasks ADD COLUMN updatedByAvatarUrl TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeId")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeId TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeName")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeName TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeEmail")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeEmail TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeAvatarUrl")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeAvatarUrl TEXT;");
}
}
function safeParseArray<T>(value: string | null, fallback: T[]): T[] { function safeParseArray<T>(value: string | null, fallback: T[]): T[] {
if (!value) return fallback; if (!value) return fallback;
try { try {
@ -127,21 +171,22 @@ function normalizeAttachments(attachments: unknown): TaskAttachment[] {
function normalizeComments(comments: unknown): TaskComment[] { function normalizeComments(comments: unknown): TaskComment[] {
if (!Array.isArray(comments)) return []; if (!Array.isArray(comments)) return [];
return comments const normalized: TaskComment[] = [];
.map((entry) => { for (const entry of comments) {
if (!entry || typeof entry !== "object") return null; if (!entry || typeof entry !== "object") continue;
const value = entry as Partial<TaskComment>; const value = entry as Partial<TaskComment>;
if (typeof value.id !== "string" || typeof value.text !== "string") return null; if (typeof value.id !== "string" || typeof value.text !== "string") continue;
return { normalized.push({
id: value.id, id: value.id,
text: value.text, text: value.text,
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(), createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
author: normalizeCommentAuthor(value.author), author: normalizeCommentAuthor(value.author),
replies: normalizeComments(value.replies), replies: normalizeComments(value.replies),
}; });
}) }
.filter((comment): comment is TaskComment => comment !== null);
return normalized;
} }
function normalizeCommentAuthor(author: unknown): TaskCommentAuthor { function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
@ -170,8 +215,9 @@ function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
? "Assistant" ? "Assistant"
: "User"; : "User";
const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined; const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined;
const avatarUrl = typeof value.avatarUrl === "string" && value.avatarUrl.trim().length > 0 ? value.avatarUrl : undefined;
return { id, name, email, type }; return { id, name, email, avatarUrl, type };
} }
function normalizeTask(task: Partial<Task>): Task { function normalizeTask(task: Partial<Task>): Task {
@ -188,8 +234,14 @@ function normalizeTask(task: Partial<Task>): Task {
updatedAt: task.updatedAt || new Date().toISOString(), updatedAt: task.updatedAt || new Date().toISOString(),
createdById: typeof task.createdById === "string" && task.createdById.trim().length > 0 ? task.createdById : undefined, 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, createdByName: typeof task.createdByName === "string" && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
createdByAvatarUrl: typeof task.createdByAvatarUrl === "string" && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : undefined,
updatedById: typeof task.updatedById === "string" && task.updatedById.trim().length > 0 ? task.updatedById : 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, updatedByName: typeof task.updatedByName === "string" && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
updatedByAvatarUrl: typeof task.updatedByAvatarUrl === "string" && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined,
assigneeId: typeof task.assigneeId === "string" && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined,
assigneeName: typeof task.assigneeName === "string" && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined,
assigneeEmail: typeof task.assigneeEmail === "string" && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined,
assigneeAvatarUrl: typeof task.assigneeAvatarUrl === "string" && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined,
dueDate: task.dueDate || undefined, dueDate: task.dueDate || undefined,
comments: normalizeComments(task.comments), comments: normalizeComments(task.comments),
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [], tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
@ -228,8 +280,8 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt) VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
`); `);
const insertTask = database.prepare(` const insertTask = database.prepare(`
INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, updatedById, updatedByName, dueDate, comments, tags, attachments) INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, createdByAvatarUrl, updatedById, updatedByName, updatedByAvatarUrl, assigneeId, assigneeName, assigneeEmail, assigneeAvatarUrl, dueDate, comments, tags, attachments)
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @updatedById, @updatedByName, @dueDate, @comments, @tags, @attachments) VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @createdByAvatarUrl, @updatedById, @updatedByName, @updatedByAvatarUrl, @assigneeId, @assigneeName, @assigneeEmail, @assigneeAvatarUrl, @dueDate, @comments, @tags, @attachments)
`); `);
for (const project of payload.projects) { for (const project of payload.projects) {
@ -261,8 +313,14 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
sprintId: task.sprintId ?? null, sprintId: task.sprintId ?? null,
createdById: task.createdById ?? null, createdById: task.createdById ?? null,
createdByName: task.createdByName ?? null, createdByName: task.createdByName ?? null,
createdByAvatarUrl: task.createdByAvatarUrl ?? null,
updatedById: task.updatedById ?? null, updatedById: task.updatedById ?? null,
updatedByName: task.updatedByName ?? null, updatedByName: task.updatedByName ?? null,
updatedByAvatarUrl: task.updatedByAvatarUrl ?? null,
assigneeId: task.assigneeId ?? null,
assigneeName: task.assigneeName ?? null,
assigneeEmail: task.assigneeEmail ?? null,
assigneeAvatarUrl: task.assigneeAvatarUrl ?? null,
dueDate: task.dueDate ?? null, dueDate: task.dueDate ?? null,
comments: JSON.stringify(task.comments ?? []), comments: JSON.stringify(task.comments ?? []),
tags: JSON.stringify(task.tags ?? []), tags: JSON.stringify(task.tags ?? []),
@ -293,7 +351,10 @@ function seedIfEmpty(database: SqliteDb) {
} }
function getDb(): SqliteDb { function getDb(): SqliteDb {
if (db) return db; if (db) {
ensureTaskSchema(db);
return db;
}
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
const database = new Database(DB_FILE); const database = new Database(DB_FILE);
@ -331,8 +392,14 @@ function getDb(): SqliteDb {
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
createdById TEXT, createdById TEXT,
createdByName TEXT, createdByName TEXT,
createdByAvatarUrl TEXT,
updatedById TEXT, updatedById TEXT,
updatedByName TEXT, updatedByName TEXT,
updatedByAvatarUrl TEXT,
assigneeId TEXT,
assigneeName TEXT,
assigneeEmail TEXT,
assigneeAvatarUrl TEXT,
dueDate TEXT, dueDate TEXT,
comments TEXT NOT NULL DEFAULT '[]', comments TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
@ -345,22 +412,7 @@ function getDb(): SqliteDb {
); );
`); `);
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; ensureTaskSchema(database);
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); seedIfEmpty(database);
db = database; db = database;
@ -402,8 +454,14 @@ export function getData(): DataStore {
updatedAt: string; updatedAt: string;
createdById: string | null; createdById: string | null;
createdByName: string | null; createdByName: string | null;
createdByAvatarUrl: string | null;
updatedById: string | null; updatedById: string | null;
updatedByName: string | null; updatedByName: string | null;
updatedByAvatarUrl: string | null;
assigneeId: string | null;
assigneeName: string | null;
assigneeEmail: string | null;
assigneeAvatarUrl: string | null;
dueDate: string | null; dueDate: string | null;
comments: string | null; comments: string | null;
tags: string | null; tags: string | null;
@ -441,8 +499,14 @@ export function getData(): DataStore {
updatedAt: task.updatedAt, updatedAt: task.updatedAt,
createdById: task.createdById ?? undefined, createdById: task.createdById ?? undefined,
createdByName: task.createdByName ?? undefined, createdByName: task.createdByName ?? undefined,
createdByAvatarUrl: task.createdByAvatarUrl ?? undefined,
updatedById: task.updatedById ?? undefined, updatedById: task.updatedById ?? undefined,
updatedByName: task.updatedByName ?? undefined, updatedByName: task.updatedByName ?? undefined,
updatedByAvatarUrl: task.updatedByAvatarUrl ?? undefined,
assigneeId: task.assigneeId ?? undefined,
assigneeName: task.assigneeName ?? undefined,
assigneeEmail: task.assigneeEmail ?? undefined,
assigneeAvatarUrl: task.assigneeAvatarUrl ?? undefined,
dueDate: task.dueDate ?? undefined, dueDate: task.dueDate ?? undefined,
comments: normalizeComments(safeParseArray(task.comments, [])), comments: normalizeComments(safeParseArray(task.comments, [])),
tags: safeParseArray(task.tags, []), tags: safeParseArray(task.tags, []),

View File

@ -29,6 +29,7 @@ export interface CommentAuthor {
id: string id: string
name: string name: string
email?: string email?: string
avatarUrl?: string
type: 'human' | 'assistant' type: 'human' | 'assistant'
} }
@ -36,6 +37,7 @@ export interface UserProfile {
id: string id: string
name: string name: string
email?: string email?: string
avatarUrl?: string
} }
export interface TaskAttachment { export interface TaskAttachment {
@ -60,8 +62,14 @@ export interface Task {
updatedAt: string updatedAt: string
createdById?: string createdById?: string
createdByName?: string createdByName?: string
createdByAvatarUrl?: string
updatedById?: string updatedById?: string
updatedByName?: string updatedByName?: string
updatedByAvatarUrl?: string
assigneeId?: string
assigneeName?: string
assigneeEmail?: string
assigneeAvatarUrl?: string
dueDate?: string dueDate?: string
comments: Comment[] comments: Comment[]
tags: string[] tags: string[]
@ -418,13 +426,15 @@ const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCur
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 ? candidate.id : fallback.id 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 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 const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
return { id, name, email } const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined
return { id, name, email, avatarUrl }
} }
const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({ const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
email: profile.email, email: profile.email,
avatarUrl: profile.avatarUrl,
type: 'human', type: 'human',
}) })
@ -467,8 +477,9 @@ const normalizeCommentAuthor = (value: unknown): CommentAuthor => {
? 'Assistant' ? 'Assistant'
: 'User' : 'User'
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined
return { id, name, email, type } return { id, name, email, avatarUrl, type }
} }
const normalizeComments = (value: unknown): Comment[] => { const normalizeComments = (value: unknown): Comment[] => {
@ -521,8 +532,14 @@ const normalizeTask = (task: Task): Task => ({
attachments: normalizeAttachments(task.attachments), attachments: normalizeAttachments(task.attachments),
createdById: typeof task.createdById === 'string' && task.createdById.trim().length > 0 ? task.createdById : undefined, 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, createdByName: typeof task.createdByName === 'string' && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
createdByAvatarUrl: typeof task.createdByAvatarUrl === 'string' && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : undefined,
updatedById: typeof task.updatedById === 'string' && task.updatedById.trim().length > 0 ? task.updatedById : 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, updatedByName: typeof task.updatedByName === 'string' && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
updatedByAvatarUrl: typeof task.updatedByAvatarUrl === 'string' && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined,
assigneeId: typeof task.assigneeId === 'string' && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined,
assigneeName: typeof task.assigneeName === 'string' && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined,
assigneeEmail: typeof task.assigneeEmail === 'string' && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined,
assigneeAvatarUrl: typeof task.assigneeAvatarUrl === 'string' && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined,
}) })
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] => const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
@ -667,8 +684,14 @@ export const useTaskStore = create<TaskStore>()(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
createdById: actor.id, createdById: actor.id,
createdByName: actor.name, createdByName: actor.name,
createdByAvatarUrl: actor.avatarUrl,
updatedById: actor.id, updatedById: actor.id,
updatedByName: actor.name, updatedByName: actor.name,
updatedByAvatarUrl: actor.avatarUrl,
assigneeId: task.assigneeId || actor.id,
assigneeName: task.assigneeName || actor.name,
assigneeEmail: task.assigneeEmail || actor.email,
assigneeAvatarUrl: task.assigneeAvatarUrl || actor.avatarUrl,
comments: normalizeComments([]), comments: normalizeComments([]),
attachments: normalizeAttachments(task.attachments), attachments: normalizeAttachments(task.attachments),
} }
@ -693,6 +716,7 @@ export const useTaskStore = create<TaskStore>()(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
updatedById: actor.id, updatedById: actor.id,
updatedByName: actor.name, updatedByName: actor.name,
updatedByAvatarUrl: actor.avatarUrl,
} as Task) } as Task)
: t : t
) )
@ -780,6 +804,7 @@ export const useTaskStore = create<TaskStore>()(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
updatedById: updater.id, updatedById: updater.id,
updatedByName: updater.name, updatedByName: updater.name,
updatedByAvatarUrl: updater.avatarUrl,
} }
: t : t
) )
@ -799,6 +824,7 @@ export const useTaskStore = create<TaskStore>()(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
updatedById: updater.id, updatedById: updater.id,
updatedByName: updater.name, updatedByName: updater.name,
updatedByAvatarUrl: updater.avatarUrl,
} }
: t : t
) )