Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
ed1d2d956a
commit
a353ed0feb
32
README.md
32
README.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/app/api/auth/users/route.ts
Normal file
17
src/app/api/auth/users/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
255
src/app/page.tsx
255
src/app/page.tsx
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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
58
src/lib/avatar.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 > ?
|
||||||
|
|||||||
@ -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, []),
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user