diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index a30c021e..49c50fc5 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,15 +1,65 @@ "use client"; +import { useState } from "react"; +import { useAuth } from "../../../lib/auth-context"; + export default function LoginPage() { + const { login, isLoading } = useAuth(); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) { + setError("Email is required"); + return; + } + setError(""); + setSubmitting(true); + try { + await login(email, name || undefined); + } catch (err) { + setError("Login failed. Make sure the server is running."); + setSubmitting(false); + } + }; + return (
-
+

Multica

AI-native task management

- -
+
); } diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 49c7e355..7ebb0c00 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,217 +1,16 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Bot, Cloud, Monitor, Plus, - Wrench, - Blocks, Zap, - GitBranch, - FileCode, - MessageSquare, - Terminal, - Database, - Globe, ListTodo, } from "lucide-react"; -import type { AgentStatus, AgentRuntimeMode } from "@multica/types"; - -// --------------------------------------------------------------------------- -// Types for mock data -// --------------------------------------------------------------------------- - -interface AgentSkill { - id: string; - name: string; - description: string; -} - -interface AgentTool { - id: string; - name: string; - icon: typeof Terminal; - connected: boolean; -} - -interface AgentTask { - id: string; - issueKey: string; - title: string; - status: "working" | "queued"; -} - -interface MockAgent { - id: string; - name: string; - avatar: string; - runtimeMode: AgentRuntimeMode; - status: AgentStatus; - model: string; - description: string; - maxConcurrentTasks: number; - host?: string; - skills: AgentSkill[]; - tools: AgentTool[]; - currentTasks: AgentTask[]; - completedTasks: number; - createdAt: string; -} - -// --------------------------------------------------------------------------- -// Mock data -// --------------------------------------------------------------------------- - -const MOCK_AGENTS: MockAgent[] = [ - { - id: "agent_1", - name: "Claude-1", - avatar: "C1", - runtimeMode: "local", - status: "working", - model: "Claude Sonnet 4", - description: - "General-purpose coding agent for backend development. Specializes in Go API development, database migrations, and test writing.", - maxConcurrentTasks: 2, - host: "jiayuan-macbook", - skills: [ - { - id: "sk_1", - name: "Go API Development", - description: "Build RESTful APIs with Chi, implement CRUD handlers, add middleware", - }, - { - id: "sk_2", - name: "Database Migrations", - description: "Create and run PostgreSQL migrations, update sqlc queries", - }, - { - id: "sk_3", - name: "Test Writing", - description: "Write Go unit and integration tests with testcontainers", - }, - ], - tools: [ - { id: "t_1", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_2", name: "Terminal", icon: Terminal, connected: true }, - { id: "t_3", name: "PostgreSQL", icon: Database, connected: true }, - { id: "t_4", name: "Browser", icon: Globe, connected: false }, - ], - currentTasks: [ - { - id: "iss_9", - issueKey: "MUL-9", - title: "Implement issue list API endpoint", - status: "working", - }, - { - id: "iss_14", - issueKey: "MUL-14", - title: "Add WebSocket event types for agent status", - status: "queued", - }, - ], - completedTasks: 12, - createdAt: "2026-03-15T10:00:00Z", - }, - { - id: "agent_2", - name: "Codex-1", - avatar: "CX", - runtimeMode: "cloud", - status: "idle", - model: "GPT-5.3 Codex", - description: - "Cloud-hosted coding agent optimized for frontend development. Handles React components, styling, and TypeScript refactoring.", - maxConcurrentTasks: 4, - skills: [ - { - id: "sk_4", - name: "React Components", - description: "Build UI components with React, Radix UI, and Tailwind CSS", - }, - { - id: "sk_5", - name: "TypeScript Refactoring", - description: "Refactor code for type safety, extract shared types and utilities", - }, - ], - tools: [ - { id: "t_5", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_6", name: "Terminal", icon: Terminal, connected: true }, - { id: "t_7", name: "Browser", icon: Globe, connected: true }, - { id: "t_8", name: "Figma", icon: FileCode, connected: false }, - ], - currentTasks: [], - completedTasks: 8, - createdAt: "2026-03-16T14:00:00Z", - }, - { - id: "agent_3", - name: "Review Bot", - avatar: "RB", - runtimeMode: "cloud", - status: "working", - model: "Claude Sonnet 4", - description: - "Automated code reviewer. Analyzes PRs for correctness, security issues, and adherence to team coding standards.", - maxConcurrentTasks: 8, - skills: [ - { - id: "sk_6", - name: "Code Review", - description: "Review pull requests for bugs, security issues, and style violations", - }, - { - id: "sk_7", - name: "Security Audit", - description: "Check for OWASP top 10 vulnerabilities and insecure patterns", - }, - ], - tools: [ - { id: "t_9", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_10", name: "Comments", icon: MessageSquare, connected: true }, - ], - currentTasks: [ - { - id: "iss_pr47", - issueKey: "PR-47", - title: "Review: Add WebSocket reconnection logic", - status: "working", - }, - ], - completedTasks: 34, - createdAt: "2026-03-14T09:00:00Z", - }, - { - id: "agent_4", - name: "Claude-2", - avatar: "C2", - runtimeMode: "local", - status: "offline", - model: "Claude Sonnet 4", - description: - "Secondary local agent on Bohan's machine. Used for documentation and knowledge base tasks.", - maxConcurrentTasks: 1, - host: "bohan-macbook", - skills: [ - { - id: "sk_8", - name: "Documentation", - description: "Write and update technical docs, API references, and README files", - }, - ], - tools: [ - { id: "t_11", name: "GitHub", icon: GitBranch, connected: true }, - { id: "t_12", name: "Terminal", icon: Terminal, connected: true }, - ], - currentTasks: [], - completedTasks: 5, - createdAt: "2026-03-18T16:00:00Z", - }, -]; +import type { Agent, AgentStatus } from "@multica/types"; +import { api } from "../../../lib/api"; // --------------------------------------------------------------------------- // Helpers @@ -225,6 +24,15 @@ const statusConfig: Record w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- @@ -234,7 +42,7 @@ function AgentListItem({ isSelected, onClick, }: { - agent: MockAgent; + agent: Agent; isSelected: boolean; onClick: () => void; }) { @@ -247,15 +55,14 @@ function AgentListItem({ isSelected ? "bg-accent" : "hover:bg-accent/50" }`} > - {/* Avatar */}
- {agent.avatar} + {getInitials(agent.name)}
{agent.name} - {agent.runtimeMode === "cloud" ? ( + {agent.runtime_mode === "cloud" ? ( ) : ( @@ -264,38 +71,13 @@ function AgentListItem({
{st.label} - {agent.currentTasks.length > 0 && ( - - · {agent.currentTasks.length} task{agent.currentTasks.length > 1 ? "s" : ""} - - )}
); } -function SectionHeader({ - icon: Icon, - title, - count, -}: { - icon: typeof Wrench; - title: string; - count?: number; -}) { - return ( -
- -

{title}

- {count !== undefined && ( - ({count}) - )} -
- ); -} - -function AgentDetail({ agent }: { agent: MockAgent }) { +function AgentDetail({ agent }: { agent: Agent }) { const st = statusConfig[agent.status]; return ( @@ -303,7 +85,7 @@ function AgentDetail({ agent }: { agent: MockAgent }) { {/* Header */}
- {agent.avatar} + {getInitials(agent.name)}
@@ -313,7 +95,9 @@ function AgentDetail({ agent }: { agent: MockAgent }) { {st.label}
-

{agent.description}

+

+ {agent.runtime_mode === "cloud" ? "Cloud-hosted" : "Local"} agent +

@@ -322,101 +106,53 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
Runtime
- {agent.runtimeMode === "cloud" ? ( + {agent.runtime_mode === "cloud" ? ( ) : ( )} - {agent.runtimeMode === "cloud" ? "Cloud" : "Local"} - {agent.host && ( - ({agent.host}) - )} + {agent.runtime_mode === "cloud" ? "Cloud" : "Local"}
-
Model
-
{agent.model}
+
Visibility
+
{agent.visibility}
-
Concurrency
+
Max Concurrent Tasks
+
{agent.max_concurrent_tasks}
+
+
+
Created
- {agent.currentTasks.filter((t) => t.status === "working").length} / {agent.maxConcurrentTasks} slots + {new Date(agent.created_at).toLocaleDateString()}
-
-
Completed Tasks
-
{agent.completedTasks}
-
- {/* Skills */} + {/* Status */}
- -
- {agent.skills.map((skill) => ( -
-
{skill.name}
-
- {skill.description} -
-
- ))} +
+ +

Status

-
- - {/* Connected Tools */} -
- -
- {agent.tools.map((tool) => ( -
- - {tool.name} - {tool.connected ? ( - Connected - ) : ( - Not set up - )} -
- ))} -
-
- - {/* Current Tasks */} -
- - {agent.currentTasks.length > 0 ? ( -
- {agent.currentTasks.map((task) => ( -
- - {task.issueKey} - - {task.title} - - {task.status === "working" ? "Working" : "Queued"} - -
- ))} +
+
+ + {st.label}
- ) : ( -

No active tasks

- )} +
+
+ + {/* Tasks placeholder */} +
+
+ +

Tasks

+
+

+ Task queue will be shown here when agents are assigned issues. +

); @@ -427,8 +163,30 @@ function AgentDetail({ agent }: { agent: MockAgent }) { // --------------------------------------------------------------------------- export default function AgentsPage() { - const [selectedId, setSelectedId] = useState(MOCK_AGENTS[0]?.id ?? ""); - const selected = MOCK_AGENTS.find((a) => a.id === selectedId) ?? null; + const [agents, setAgents] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .listAgents() + .then((a) => { + setAgents(a); + if (a.length > 0) setSelectedId(a[0]!.id); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const selected = agents.find((a) => a.id === selectedId) ?? null; + + if (loading) { + return ( +
+ Loading... +
+ ); + } return (
@@ -441,7 +199,7 @@ export default function AgentsPage() {
- {MOCK_AGENTS.map((agent) => ( + {agents.map((agent) => ( void; }) { - const Icon = typeIcons[item.type]; + const Icon = typeIcons[item.type] ?? CircleDot; const colorClass = severityColors[item.severity]; return (
- {item.type === "agent_blocked" || item.type === "review_requested" ? ( + {(item.type === "agent_blocked" || item.type === "review_requested") && (
Agent action
- ) : null} + )}
{!item.read && ( @@ -199,8 +93,14 @@ function InboxListItem({ ); } -function InboxDetail({ item }: { item: InboxItem }) { - const Icon = typeIcons[item.type]; +function InboxDetail({ + item, + onMarkRead, +}: { + item: InboxItem; + onMarkRead: (id: string) => void; +}) { + const Icon = typeIcons[item.type] ?? CircleDot; const colorClass = severityColors[item.severity]; const severityLabel: Record = { @@ -220,14 +120,16 @@ function InboxDetail({ item }: { item: InboxItem }) { {severityLabel[item.severity]} · {timeAgo(item.created_at)} - {item.issue_id && ( - <> - · - {item.issue_id} - - )} + {!item.read && ( + + )} {/* Body */} @@ -245,14 +147,47 @@ function InboxDetail({ item }: { item: InboxItem }) { // --------------------------------------------------------------------------- export default function InboxPage() { - const sorted = [...MOCK_INBOX_ITEMS].sort( - (a, b) => - severityOrder[a.severity] - severityOrder[b.severity] || - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [loading, setLoading] = useState(true); - const [selectedId, setSelectedId] = useState(sorted[0]?.id ?? ""); - const selected = sorted.find((i) => i.id === selectedId) ?? null; + useEffect(() => { + api + .listInbox() + .then((data) => { + const sorted = [...data].sort( + (a, b) => + severityOrder[a.severity] - severityOrder[b.severity] || + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + setItems(sorted); + if (sorted.length > 0) setSelectedId(sorted[0]!.id); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const handleMarkRead = async (id: string) => { + try { + await api.markInboxRead(id); + setItems((prev) => + prev.map((i) => (i.id === id ? { ...i, read: true } : i)) + ); + } catch (err) { + console.error("Failed to mark read:", err); + } + }; + + const selected = items.find((i) => i.id === selectedId) ?? null; + const unreadCount = items.filter((i) => !i.read).length; + + if (loading) { + return ( +
+ Loading... +
+ ); + } return (
@@ -260,29 +195,39 @@ export default function InboxPage() {

Inbox

- - {sorted.filter((i) => !i.read).length} - -
-
- {sorted.map((item) => ( - setSelectedId(item.id)} - /> - ))} + {unreadCount > 0 && ( + + {unreadCount} + + )}
+ {items.length === 0 ? ( +
+

No notifications yet

+
+ ) : ( +
+ {items.map((item) => ( + setSelectedId(item.id)} + /> + ))} +
+ )}
{/* Right column — detail */}
{selected ? ( - + ) : (
- Select an item to view details + {items.length === 0 + ? "Your inbox is empty" + : "Select an item to view details"}
)}
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 9be02a75..9c70d5ac 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -1,21 +1,17 @@ "use client"; -import { use } from "react"; +import { use, useState, useEffect } from "react"; import Link from "next/link"; import { Bot, - Calendar, ChevronRight, - User, - MessageSquare, + Send, } from "lucide-react"; -import { - MOCK_ISSUES, - STATUS_CONFIG, - PRIORITY_CONFIG, -} from "../_data/mock"; -import type { MockAssignee } from "../_data/mock"; +import type { Issue, Comment } from "@multica/types"; +import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/mock"; import { StatusIcon, PriorityIcon } from "../page"; +import { api } from "../../../../lib/api"; +import { useAuth } from "../../../../lib/auth-context"; // --------------------------------------------------------------------------- // Helpers @@ -32,16 +28,8 @@ function timeAgo(dateStr: string): string { return `${days}d ago`; } -function formatDate(date: string | null): string { +function shortDate(date: string | null): string { if (!date) return "—"; - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function shortDate(date: string): string { return new Date(date).toLocaleDateString("en-US", { month: "short", day: "numeric", @@ -52,14 +40,19 @@ function shortDate(date: string): string { // Avatar // --------------------------------------------------------------------------- -function Avatar({ - person, +function ActorAvatar({ + actorType, + actorId, size = 20, }: { - person: MockAssignee; + actorType: string; + actorId: string; size?: number; }) { - const isAgent = person.type === "agent"; + const { getActorName, getActorInitials } = useAuth(); + const name = getActorName(actorType, actorId); + const initials = getActorInitials(actorType, actorId); + const isAgent = actorType === "agent"; return (
- {isAgent ? : person.avatar.charAt(0)} + {isAgent ? ( + + ) : ( + initials + )}
); } // --------------------------------------------------------------------------- -// Property row (Linear-style: label left, clickable value right) +// Property row // --------------------------------------------------------------------------- function PropRow({ @@ -106,7 +103,45 @@ export default function IssueDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const issue = MOCK_ISSUES.find((i) => i.id === id); + const { getActorName } = useAuth(); + const [issue, setIssue] = useState(null); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [commentText, setCommentText] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + Promise.all([api.getIssue(id), api.listComments(id)]) + .then(([iss, cmts]) => { + setIssue(iss); + setComments(cmts); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, [id]); + + const handleSubmitComment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!commentText.trim() || submitting) return; + setSubmitting(true); + try { + const comment = await api.createComment(id, commentText.trim()); + setComments((prev) => [...prev, comment]); + setCommentText(""); + } catch (err) { + console.error("Failed to create comment:", err); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } if (!issue) { return ( @@ -119,31 +154,11 @@ export default function IssueDetailPage({ const statusCfg = STATUS_CONFIG[issue.status]; const priorityCfg = PRIORITY_CONFIG[issue.priority]; const isOverdue = - issue.dueDate && new Date(issue.dueDate) < new Date() && issue.status !== "done"; - - // Merge activity + comments into timeline - const timeline = [ - ...issue.activity.map((a) => ({ - id: a.id, - kind: "activity" as const, - actor: a.actor, - content: a.action, - createdAt: a.createdAt, - })), - ...issue.comments.map((c) => ({ - id: c.id, - kind: "comment" as const, - actor: c.author, - content: c.body, - createdAt: c.createdAt, - })), - ].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== "done"; return (
- {/* ================================================================ - LEFT: Content area - ================================================================ */} + {/* LEFT: Content area */}
{/* Header bar */}
@@ -154,90 +169,76 @@ export default function IssueDetailPage({ Issues - {issue.key} + {issue.id.slice(0, 8)}
{/* Content */}
- {/* Issue key */} -
{issue.key}
+
{issue.id.slice(0, 8)}
- {/* Title */}

{issue.title}

- {/* Description */} {issue.description && (
{issue.description}
)} - {/* Separator */}
- {/* Activity */} + {/* Activity / Comments */}

Activity

- {timeline.map((entry, idx) => - entry.kind === "comment" ? ( - /* ---- Comment ---- */ -
-
- - - {entry.actor.name} - - - {timeAgo(entry.createdAt)} - -
-
- {entry.content} -
-
- ) : ( - /* ---- Status change ---- */ -
- - + {comments.map((comment) => ( +
+
+ + + {getActorName(comment.author_type, comment.author_id)} - - - {entry.actor.name} - {" "} - {entry.content} + + {timeAgo(comment.created_at)} - {timeAgo(entry.createdAt)}
- ) - )} +
+ {comment.content} +
+
+ ))}
{/* Comment input */} -
-
- - - - - Leave a comment... - +
+
+ setCommentText(e.target.value)} + placeholder="Leave a comment..." + className="flex-1 rounded-md border bg-background px-3 py-2 text-[13px] placeholder:text-muted-foreground" + /> +
-
+
- {/* ================================================================ - RIGHT: Properties sidebar - ================================================================ */} + {/* RIGHT: Properties sidebar */}
@@ -256,10 +257,14 @@ export default function IssueDetailPage({ - {issue.assignee ? ( + {issue.assignee_type && issue.assignee_id ? ( <> - - {issue.assignee.name} + + {getActorName(issue.assignee_type, issue.assignee_id)} ) : ( Unassigned @@ -267,9 +272,9 @@ export default function IssueDetailPage({ - {issue.dueDate ? ( + {issue.due_date ? ( - {shortDate(issue.dueDate)} + {shortDate(issue.due_date)} ) : ( None @@ -277,17 +282,21 @@ export default function IssueDetailPage({ - - {issue.creator.name} + + {getActorName(issue.creator_type, issue.creator_id)}
- {shortDate(issue.createdAt)} + {shortDate(issue.created_at)} - {shortDate(issue.updatedAt)} + {shortDate(issue.updated_at)}
diff --git a/apps/web/app/(dashboard)/issues/_data/mock.ts b/apps/web/app/(dashboard)/issues/_data/mock.ts index d746aee6..082a0ea0 100644 --- a/apps/web/app/(dashboard)/issues/_data/mock.ts +++ b/apps/web/app/(dashboard)/issues/_data/mock.ts @@ -95,7 +95,12 @@ export const PRIORITY_CONFIG: Record< // Mock Issues // --------------------------------------------------------------------------- -const { jiayuan, bohan, yuzhen, claude1, codex1, reviewBot } = PEOPLE; +const jiayuan = PEOPLE["jiayuan"]!; +const bohan = PEOPLE["bohan"]!; +const yuzhen = PEOPLE["yuzhen"]!; +const claude1 = PEOPLE["claude1"]!; +const codex1 = PEOPLE["codex1"]!; +const reviewBot = PEOPLE["reviewBot"]!; export const MOCK_ISSUES: MockIssue[] = [ // ---- Backlog ---- diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index a033d6f1..127b606a 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import Link from "next/link"; import { Columns3, @@ -15,7 +15,6 @@ import { CircleAlert, Eye, Minus, - MessageSquare, } from "lucide-react"; import { DndContext, @@ -30,14 +29,12 @@ import { } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { IssueStatus, IssuePriority } from "@multica/types"; -import { - MOCK_ISSUES, - STATUS_CONFIG, - PRIORITY_CONFIG, - type MockIssue, - type MockAssignee, -} from "./_data/mock"; +import type { Issue, IssueStatus, IssuePriority } from "@multica/types"; +import { STATUS_CONFIG, PRIORITY_CONFIG } from "./_data/mock"; +import { api } from "../../../lib/api"; +import { useAuth } from "../../../lib/auth-context"; +import { useWSEvent } from "../../../lib/ws-context"; +import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types"; // --------------------------------------------------------------------------- // Shared icon components @@ -98,27 +95,30 @@ export function PriorityIcon({ } function AssigneeAvatar({ - assignee, + issue, size = "sm", }: { - assignee: MockAssignee | null; + issue: Issue; size?: "sm" | "md"; }) { - if (!assignee) return null; + const { getActorName, getActorInitials } = useAuth(); + if (!issue.assignee_type || !issue.assignee_id) return null; + const name = getActorName(issue.assignee_type, issue.assignee_id); + const initials = getActorInitials(issue.assignee_type, issue.assignee_id); const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs"; return (
- {assignee.type === "agent" ? ( + {issue.assignee_type === "agent" ? ( ) : ( - assignee.avatar.charAt(0) + initials )}
); @@ -132,30 +132,24 @@ function formatDate(date: string): string { } // --------------------------------------------------------------------------- -// Board View — Card (static, used in both draggable wrapper and overlay) +// Board View — Card // --------------------------------------------------------------------------- -function BoardCardContent({ issue }: { issue: MockIssue }) { +function BoardCardContent({ issue }: { issue: Issue }) { return (
- {issue.key} + {issue.id.slice(0, 8)}

{issue.title}

- - {issue.comments.length > 0 && ( - - - {issue.comments.length} - - )} +
- {issue.dueDate && ( + {issue.due_date && ( - {formatDate(issue.dueDate)} + {formatDate(issue.due_date)} )}
@@ -167,7 +161,7 @@ function BoardCardContent({ issue }: { issue: MockIssue }) { // Draggable card wrapper // --------------------------------------------------------------------------- -function DraggableBoardCard({ issue }: { issue: MockIssue }) { +function DraggableBoardCard({ issue }: { issue: Issue }) { const { attributes, listeners, @@ -196,7 +190,6 @@ function DraggableBoardCard({ issue }: { issue: MockIssue }) { { - // Prevent navigation when dragging if (isDragging) e.preventDefault(); }} className="block transition-colors hover:opacity-80" @@ -216,7 +209,7 @@ function DroppableColumn({ issues, }: { status: IssueStatus; - issues: MockIssue[]; + issues: Issue[]; }) { const cfg = STATUS_CONFIG[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); @@ -250,10 +243,10 @@ function BoardView({ issues, onMoveIssue, }: { - issues: MockIssue[]; + issues: Issue[]; onMoveIssue: (issueId: string, newStatus: IssueStatus) => void; }) { - const [activeIssue, setActiveIssue] = useState(null); + const [activeIssue, setActiveIssue] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -284,14 +277,11 @@ function BoardView({ if (!over) return; const issueId = active.id as string; - // `over.id` is the column's droppable id (a status string) - // or another card's sortable id let targetStatus: IssueStatus | undefined; if (visibleStatuses.includes(over.id as IssueStatus)) { targetStatus = over.id as IssueStatus; } else { - // Dropped on a card — find which column that card is in const targetIssue = issues.find((i) => i.id === over.id); if (targetIssue) targetStatus = targetIssue.status; } @@ -338,27 +328,29 @@ function BoardView({ // List View // --------------------------------------------------------------------------- -function ListRow({ issue }: { issue: MockIssue }) { +function ListRow({ issue }: { issue: Issue }) { return ( - {issue.key} + + {issue.id.slice(0, 8)} + {issue.title} - {issue.dueDate && ( + {issue.due_date && ( - {formatDate(issue.dueDate)} + {formatDate(issue.due_date)} )} - + ); } -function ListView({ issues }: { issues: MockIssue[] }) { +function ListView({ issues }: { issues: Issue[] }) { const groupOrder: IssueStatus[] = [ "in_review", "in_progress", @@ -390,6 +382,69 @@ function ListView({ issues }: { issues: MockIssue[] }) { ); } +// --------------------------------------------------------------------------- +// Create Issue Dialog (simple inline) +// --------------------------------------------------------------------------- + +function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) { + const [title, setTitle] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + try { + const issue = await api.createIssue({ title: title.trim() }); + onCreated(issue); + setTitle(""); + setIsOpen(false); + } catch (err) { + console.error("Failed to create issue:", err); + } + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+ setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setIsOpen(false); + }} + placeholder="Issue title..." + className="rounded-md border bg-background px-2 py-1 text-xs w-48" + /> + + +
+ ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -398,21 +453,78 @@ type ViewMode = "board" | "list"; export default function IssuesPage() { const [view, setView] = useState("board"); - const [issues, setIssues] = useState(MOCK_ISSUES); + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .listIssues({ limit: 200 }) + .then((res) => { + setIssues(res.issues); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + // Real-time updates + useWSEvent( + "issue:created", + useCallback((payload: unknown) => { + const { issue } = payload as IssueCreatedPayload; + setIssues((prev) => { + if (prev.some((i) => i.id === issue.id)) return prev; + return [...prev, issue]; + }); + }, []), + ); + + useWSEvent( + "issue:updated", + useCallback((payload: unknown) => { + const { issue } = payload as IssueUpdatedPayload; + setIssues((prev) => prev.map((i) => (i.id === issue.id ? issue : i))); + }, []), + ); + + useWSEvent( + "issue:deleted", + useCallback((payload: unknown) => { + const { issue_id } = payload as IssueDeletedPayload; + setIssues((prev) => prev.filter((i) => i.id !== issue_id)); + }, []), + ); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus) => { + // Optimistic update setIssues((prev) => prev.map((issue) => - issue.id === issueId - ? { ...issue, status: newStatus, updatedAt: new Date().toISOString() } - : issue + issue.id === issueId ? { ...issue, status: newStatus } : issue ) ); + + // Persist to API + api.updateIssue(issueId, { status: newStatus }).catch((err) => { + console.error("Failed to update issue:", err); + // Revert on error + api.listIssues({ limit: 200 }).then((res) => setIssues(res.issues)); + }); }, [] ); + const handleIssueCreated = useCallback((issue: Issue) => { + setIssues((prev) => [...prev, issue]); + }, []); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + return (
{/* Toolbar */} @@ -444,10 +556,7 @@ export default function IssuesPage() {
- +
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index e20cf58b..cd03bcf4 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -1,15 +1,22 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState, useCallback } from "react"; import { Inbox, ListTodo, Bot, BookOpen, ChevronDown, + Settings, + LogOut, + Plus, } from "lucide-react"; import { MulticaIcon } from "@multica/ui/components/multica-icon"; +import { useAuth } from "../../lib/auth-context"; +import type { Workspace } from "@multica/types"; +import { api } from "../../lib/api"; const navItems = [ { href: "/inbox", label: "Inbox", icon: Inbox }, @@ -24,18 +31,75 @@ export default function DashboardLayout({ children: React.ReactNode; }) { const pathname = usePathname(); + const router = useRouter(); + const { user, workspace, isLoading, logout } = useAuth(); + const [showMenu, setShowMenu] = useState(false); + + useEffect(() => { + if (!isLoading && !user) { + router.push("/login"); + } + }, [user, isLoading, router]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) return null; return (
- {/* Sidebar — sits on the canvas layer */} + {/* Sidebar */} - {/* Main content — floating panel on top of the canvas */} + {/* Main content */}
{children} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 1ee322b5..a8ce15d5 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,10 +1,155 @@ -export default function SettingsPage() { +"use client"; + +import { useState } from "react"; +import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react"; +import type { MemberWithUser, MemberRole } from "@multica/types"; +import { useAuth } from "../../../lib/auth-context"; +import { api } from "../../../lib/api"; + +const roleConfig: Record = { + owner: { label: "Owner", icon: Crown }, + admin: { label: "Admin", icon: Shield }, + member: { label: "Member", icon: User }, +}; + +function MemberRow({ member }: { member: MemberWithUser }) { + const rc = roleConfig[member.role]; + const RoleIcon = rc.icon; + return ( -
-

Settings

-

- Workspace settings coming soon. -

+
+
+ {member.name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2)} +
+
+
{member.name}
+
{member.email}
+
+
+ + {rc.label} +
+
+ ); +} + +export default function SettingsPage() { + const { workspace, members } = useAuth(); + + const [name, setName] = useState(workspace?.name ?? ""); + const [description, setDescription] = useState( + workspace?.description ?? "", + ); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const handleSave = async () => { + if (!workspace) return; + setSaving(true); + try { + await api.updateWorkspace(workspace.id, { + name, + description: description || undefined, + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + console.error("Failed to update workspace", e); + } finally { + setSaving(false); + } + }; + + if (!workspace) return null; + + return ( +
+ {/* Page header */} +
+ +

Settings

+
+ + {/* Workspace info */} +
+
+ +

Workspace

+
+ +
+
+ + setName(e.target.value)} + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ +