Resolve assignee avatars via user profiles
This commit is contained in:
parent
f4123a2d9a
commit
5b3691826b
@ -67,7 +67,6 @@ export async function POST(request: Request) {
|
|||||||
assigneeId: task.assigneeId || user.id,
|
assigneeId: task.assigneeId || user.id,
|
||||||
assigneeName: task.assigneeName || user.name,
|
assigneeName: task.assigneeName || user.name,
|
||||||
assigneeEmail: task.assigneeEmail || user.email,
|
assigneeEmail: task.assigneeEmail || user.email,
|
||||||
assigneeAvatarUrl: task.assigneeAvatarUrl || user.avatarUrl,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,7 +83,7 @@ export async function POST(request: Request) {
|
|||||||
assigneeId: entry.assigneeId || undefined,
|
assigneeId: entry.assigneeId || undefined,
|
||||||
assigneeName: entry.assigneeName || undefined,
|
assigneeName: entry.assigneeName || undefined,
|
||||||
assigneeEmail: entry.assigneeEmail || undefined,
|
assigneeEmail: entry.assigneeEmail || undefined,
|
||||||
assigneeAvatarUrl: entry.assigneeAvatarUrl || undefined,
|
assigneeAvatarUrl: undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -219,11 +219,13 @@ function KanbanDropColumn({
|
|||||||
function KanbanTaskCard({
|
function KanbanTaskCard({
|
||||||
task,
|
task,
|
||||||
taskTags,
|
taskTags,
|
||||||
|
assigneeAvatarUrl,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
task: Task
|
task: Task
|
||||||
taskTags: string[]
|
taskTags: string[]
|
||||||
|
assigneeAvatarUrl?: string
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
@ -307,7 +309,7 @@ function KanbanTaskCard({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AvatarCircle
|
<AvatarCircle
|
||||||
name={task.assigneeName || "Unassigned"}
|
name={task.assigneeName || "Unassigned"}
|
||||||
avatarUrl={task.assigneeAvatarUrl}
|
avatarUrl={assigneeAvatarUrl}
|
||||||
seed={task.assigneeId}
|
seed={task.assigneeId}
|
||||||
title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"}
|
title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"}
|
||||||
/>
|
/>
|
||||||
@ -1002,6 +1004,7 @@ export default function Home() {
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
taskTags={getTags(task)}
|
taskTags={getTags(task)}
|
||||||
|
assigneeAvatarUrl={resolveAssignee(task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl}
|
||||||
onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
||||||
onDelete={() => deleteTask(task.id)}
|
onDelete={() => deleteTask(task.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -684,7 +684,7 @@ export default function TaskDetailPage() {
|
|||||||
<span className="inline-flex items-center gap-2 text-xs text-slate-300">
|
<span className="inline-flex items-center gap-2 text-xs text-slate-300">
|
||||||
<AvatarCircle
|
<AvatarCircle
|
||||||
name={editedTask.assigneeName || "Unassigned"}
|
name={editedTask.assigneeName || "Unassigned"}
|
||||||
avatarUrl={editedTask.assigneeAvatarUrl}
|
avatarUrl={resolveAssignee(editedTask.assigneeId)?.avatarUrl || editedTask.assigneeAvatarUrl}
|
||||||
seed={editedTask.assigneeId}
|
seed={editedTask.assigneeId}
|
||||||
sizeClass="h-6 w-6"
|
sizeClass="h-6 w-6"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react"
|
import { useEffect, useState, type ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@ -42,6 +42,13 @@ const typeLabels: Record<string, string> = {
|
|||||||
plan: "📐",
|
plan: "📐",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssignableUser {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) {
|
function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) {
|
||||||
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned")
|
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned")
|
||||||
return (
|
return (
|
||||||
@ -57,9 +64,11 @@ function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?:
|
|||||||
// Sortable Task Row
|
// Sortable Task Row
|
||||||
function SortableTaskRow({
|
function SortableTaskRow({
|
||||||
task,
|
task,
|
||||||
|
assigneeAvatarUrl,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
task: Task
|
task: Task
|
||||||
|
assigneeAvatarUrl?: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@ -103,13 +112,13 @@ 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} />
|
<AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag Overlay Item
|
// Drag Overlay Item
|
||||||
function DragOverlayItem({ task }: { task: Task }) {
|
function DragOverlayItem({ task, assigneeAvatarUrl }: { task: Task; assigneeAvatarUrl?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 p-3 bg-slate-800 border border-slate-600 rounded-lg shadow-xl rotate-1">
|
<div className="flex items-center gap-3 p-3 bg-slate-800 border border-slate-600 rounded-lg shadow-xl rotate-1">
|
||||||
<GripVertical className="w-4 h-4 text-slate-500" />
|
<GripVertical className="w-4 h-4 text-slate-500" />
|
||||||
@ -120,7 +129,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} />
|
<AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -145,6 +154,7 @@ function TaskSection({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onToggle,
|
onToggle,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
|
resolveAssigneeAvatar,
|
||||||
sprintInfo,
|
sprintInfo,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@ -152,6 +162,7 @@ function TaskSection({
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
onTaskClick: (task: Task) => void
|
onTaskClick: (task: Task) => void
|
||||||
|
resolveAssigneeAvatar: (task: Task) => string | undefined
|
||||||
sprintInfo?: { name: string; date: string; status: string }
|
sprintInfo?: { name: string; date: string; status: string }
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -196,6 +207,7 @@ function TaskSection({
|
|||||||
<SortableTaskRow
|
<SortableTaskRow
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
assigneeAvatarUrl={resolveAssigneeAvatar(task)}
|
||||||
onClick={() => onTaskClick(task)}
|
onClick={() => onTaskClick(task)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@ -210,6 +222,7 @@ function TaskSection({
|
|||||||
|
|
||||||
export function BacklogView() {
|
export function BacklogView() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
@ -218,6 +231,37 @@ export function BacklogView() {
|
|||||||
addSprint,
|
addSprint,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/users", { cache: "no-store" })
|
||||||
|
if (!response.ok) return
|
||||||
|
const data = await response.json()
|
||||||
|
if (!active || !Array.isArray(data?.users)) return
|
||||||
|
setAssignableUsers(
|
||||||
|
data.users.map((entry: { id: string; name: string; email?: string; avatarUrl?: string }) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
email: entry.email,
|
||||||
|
avatarUrl: entry.avatarUrl,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Keep backlog usable if users lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadUsers()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resolveAssigneeAvatar = (task: Task) => {
|
||||||
|
if (!task.assigneeId) return task.assigneeAvatarUrl
|
||||||
|
return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null)
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||||
current: true,
|
current: true,
|
||||||
@ -331,6 +375,7 @@ export function BacklogView() {
|
|||||||
isOpen={openSections.current}
|
isOpen={openSections.current}
|
||||||
onToggle={() => toggleSection("current")}
|
onToggle={() => toggleSection("current")}
|
||||||
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
||||||
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
||||||
sprintInfo={
|
sprintInfo={
|
||||||
currentSprint
|
currentSprint
|
||||||
? {
|
? {
|
||||||
@ -362,6 +407,7 @@ export function BacklogView() {
|
|||||||
isOpen={openSections[sprint.id] ?? false}
|
isOpen={openSections[sprint.id] ?? false}
|
||||||
onToggle={() => toggleSection(sprint.id)}
|
onToggle={() => toggleSection(sprint.id)}
|
||||||
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
||||||
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
||||||
sprintInfo={{
|
sprintInfo={{
|
||||||
name: sprint.name,
|
name: sprint.name,
|
||||||
date: (() => {
|
date: (() => {
|
||||||
@ -439,12 +485,13 @@ export function BacklogView() {
|
|||||||
isOpen={openSections.backlog}
|
isOpen={openSections.backlog}
|
||||||
onToggle={() => toggleSection("backlog")}
|
onToggle={() => toggleSection("backlog")}
|
||||||
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
||||||
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
||||||
/>
|
/>
|
||||||
</SectionDropZone>
|
</SectionDropZone>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeTask ? <DragOverlayItem task={activeTask} /> : null}
|
{activeTask ? <DragOverlayItem task={activeTask} assigneeAvatarUrl={resolveAssigneeAvatar(activeTask)} /> : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -98,6 +98,13 @@ type SqliteDb = InstanceType<typeof Database>;
|
|||||||
|
|
||||||
let db: SqliteDb | null = null;
|
let db: SqliteDb | null = null;
|
||||||
|
|
||||||
|
interface UserProfileLookup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureTaskSchema(database: SqliteDb) {
|
function ensureTaskSchema(database: SqliteDb) {
|
||||||
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
if (!taskColumns.some((column) => column.name === "attachments")) {
|
if (!taskColumns.some((column) => column.name === "attachments")) {
|
||||||
@ -145,6 +152,32 @@ function safeParseArray<T>(value: string | null, fallback: T[]): T[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserLookup(database: SqliteDb): Map<string, UserProfileLookup> {
|
||||||
|
const hasUsersTable = database
|
||||||
|
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1")
|
||||||
|
.get() as { 1: number } | undefined;
|
||||||
|
if (!hasUsersTable) return new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = database
|
||||||
|
.prepare("SELECT id, name, email, avatarUrl FROM users")
|
||||||
|
.all() as Array<{ id: string; name: string; email: string | null; avatarUrl: string | null }>;
|
||||||
|
|
||||||
|
const lookup = new Map<string, UserProfileLookup>();
|
||||||
|
for (const row of rows) {
|
||||||
|
lookup.set(row.id, {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email ?? undefined,
|
||||||
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lookup;
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAttachments(attachments: unknown): TaskAttachment[] {
|
function normalizeAttachments(attachments: unknown): TaskAttachment[] {
|
||||||
if (!Array.isArray(attachments)) return [];
|
if (!Array.isArray(attachments)) return [];
|
||||||
|
|
||||||
@ -421,6 +454,7 @@ function getDb(): SqliteDb {
|
|||||||
|
|
||||||
export function getData(): DataStore {
|
export function getData(): DataStore {
|
||||||
const database = getDb();
|
const database = getDb();
|
||||||
|
const usersById = getUserLookup(database);
|
||||||
|
|
||||||
const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{
|
const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -486,32 +520,38 @@ export function getData(): DataStore {
|
|||||||
projectId: sprint.projectId,
|
projectId: sprint.projectId,
|
||||||
createdAt: sprint.createdAt,
|
createdAt: sprint.createdAt,
|
||||||
})),
|
})),
|
||||||
tasks: tasks.map((task) => ({
|
tasks: tasks.map((task) => {
|
||||||
id: task.id,
|
const createdByUser = task.createdById ? usersById.get(task.createdById) : undefined;
|
||||||
title: task.title,
|
const updatedByUser = task.updatedById ? usersById.get(task.updatedById) : undefined;
|
||||||
description: task.description ?? undefined,
|
const assigneeUser = task.assigneeId ? usersById.get(task.assigneeId) : undefined;
|
||||||
type: task.type,
|
|
||||||
status: task.status,
|
return {
|
||||||
priority: task.priority,
|
id: task.id,
|
||||||
projectId: task.projectId,
|
title: task.title,
|
||||||
sprintId: task.sprintId ?? undefined,
|
description: task.description ?? undefined,
|
||||||
createdAt: task.createdAt,
|
type: task.type,
|
||||||
updatedAt: task.updatedAt,
|
status: task.status,
|
||||||
createdById: task.createdById ?? undefined,
|
priority: task.priority,
|
||||||
createdByName: task.createdByName ?? undefined,
|
projectId: task.projectId,
|
||||||
createdByAvatarUrl: task.createdByAvatarUrl ?? undefined,
|
sprintId: task.sprintId ?? undefined,
|
||||||
updatedById: task.updatedById ?? undefined,
|
createdAt: task.createdAt,
|
||||||
updatedByName: task.updatedByName ?? undefined,
|
updatedAt: task.updatedAt,
|
||||||
updatedByAvatarUrl: task.updatedByAvatarUrl ?? undefined,
|
createdById: task.createdById ?? undefined,
|
||||||
assigneeId: task.assigneeId ?? undefined,
|
createdByName: task.createdByName ?? createdByUser?.name ?? undefined,
|
||||||
assigneeName: task.assigneeName ?? undefined,
|
createdByAvatarUrl: createdByUser?.avatarUrl ?? task.createdByAvatarUrl ?? undefined,
|
||||||
assigneeEmail: task.assigneeEmail ?? undefined,
|
updatedById: task.updatedById ?? undefined,
|
||||||
assigneeAvatarUrl: task.assigneeAvatarUrl ?? undefined,
|
updatedByName: task.updatedByName ?? updatedByUser?.name ?? undefined,
|
||||||
dueDate: task.dueDate ?? undefined,
|
updatedByAvatarUrl: updatedByUser?.avatarUrl ?? task.updatedByAvatarUrl ?? undefined,
|
||||||
comments: normalizeComments(safeParseArray(task.comments, [])),
|
assigneeId: task.assigneeId ?? undefined,
|
||||||
tags: safeParseArray(task.tags, []),
|
assigneeName: assigneeUser?.name ?? task.assigneeName ?? undefined,
|
||||||
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
|
assigneeEmail: assigneeUser?.email ?? task.assigneeEmail ?? undefined,
|
||||||
})),
|
assigneeAvatarUrl: assigneeUser?.avatarUrl ?? undefined,
|
||||||
|
dueDate: task.dueDate ?? undefined,
|
||||||
|
comments: normalizeComments(safeParseArray(task.comments, [])),
|
||||||
|
tags: safeParseArray(task.tags, []),
|
||||||
|
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
|
||||||
|
};
|
||||||
|
}),
|
||||||
lastUpdated: getLastUpdated(database),
|
lastUpdated: getLastUpdated(database),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user