refactor(web): restructure to feature-based architecture with zustand stores
- Remove tab system entirely (tab-store, tab-bar, tab-link) - Split monolithic AuthContext into zustand auth + workspace stores - Move issue components/config to features/issues/ - Move WebSocket provider to features/realtime/ - Move api.ts to shared/ - Migrate all consumers from useAuth() to direct store imports - Simplify sidebar: replace hand-built dropdown with shadcn DropdownMenu, replace custom layout wrapper with SidebarInset - Remove unused @multica/store and @multica/hooks dependencies - Add @/ path alias and zustand dependency - Update CLAUDE.md with feature-based architecture conventions Net change: +293 / -2435 lines Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
768a555a80
commit
a2d7501d57
48 changed files with 604 additions and 1704 deletions
2
apps/web/features/auth/index.ts
Normal file
2
apps/web/features/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useAuthStore } from "./store";
|
||||
export { AuthInitializer } from "./initializer";
|
||||
32
apps/web/features/auth/initializer.tsx
Normal file
32
apps/web/features/auth/initializer.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useAuthStore } from "./store";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
/**
|
||||
* Initializes auth + workspace state from localStorage on mount.
|
||||
* Must wrap the app to ensure stores are hydrated before children render.
|
||||
*/
|
||||
export function AuthInitializer({ children }: { children: ReactNode }) {
|
||||
const initialize = useAuthStore((s) => s.initialize);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) return;
|
||||
const wsId = localStorage.getItem("multica_workspace_id");
|
||||
|
||||
api.listWorkspaces().then((wsList) => {
|
||||
hydrateWorkspace(wsList, wsId);
|
||||
}).catch(console.error);
|
||||
}, [user, isLoading, hydrateWorkspace]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
61
apps/web/features/auth/store.ts
Normal file
61
apps/web/features/auth/store.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { User } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
|
||||
initialize: () => Promise<void>;
|
||||
login: (email: string, name?: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api.setToken(token);
|
||||
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (email: string, name?: string) => {
|
||||
const { token, user } = await api.login(email, name);
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
}));
|
||||
3
apps/web/features/issues/components/index.ts
Normal file
3
apps/web/features/issues/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { StatusIcon } from "./status-icon";
|
||||
export { PriorityIcon } from "./priority-icon";
|
||||
export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
|
||||
145
apps/web/features/issues/components/pickers/assignee-picker.tsx
Normal file
145
apps/web/features/issues/components/pickers/assignee-picker.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bot, UserMinus } from "lucide-react";
|
||||
import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerSection,
|
||||
PickerEmpty,
|
||||
} from "./property-picker";
|
||||
|
||||
export function AssigneePicker({
|
||||
assigneeType,
|
||||
assigneeId,
|
||||
onUpdate,
|
||||
}: {
|
||||
assigneeType: IssueAssigneeType | null;
|
||||
assigneeId: string | null;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
|
||||
const query = filter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
assigneeType === type && assigneeId === id;
|
||||
|
||||
const triggerLabel =
|
||||
assigneeType && assigneeId
|
||||
? getActorName(assigneeType, assigneeId)
|
||||
: "Unassigned";
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={(v: boolean) => {
|
||||
setOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-52"
|
||||
searchable
|
||||
searchPlaceholder="Assign to..."
|
||||
onSearchChange={setFilter}
|
||||
trigger={
|
||||
assigneeType && assigneeId ? (
|
||||
<>
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] ${
|
||||
assigneeType === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
style={{ width: 18, height: 18 }}
|
||||
>
|
||||
{assigneeType === "agent" ? (
|
||||
<Bot style={{ width: 10, height: 10 }} />
|
||||
) : (
|
||||
getActorInitials(assigneeType, assigneeId)
|
||||
)}
|
||||
</div>
|
||||
<span>{triggerLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Unassigned option */}
|
||||
<PickerItem
|
||||
selected={!assigneeType && !assigneeId}
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: null, assignee_id: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</PickerItem>
|
||||
|
||||
{/* Members */}
|
||||
{filteredMembers.length > 0 && (
|
||||
<PickerSection label="Members">
|
||||
{filteredMembers.map((m) => (
|
||||
<PickerItem
|
||||
key={m.user_id}
|
||||
selected={isSelected("member", m.user_id)}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
assignee_type: "member",
|
||||
assignee_id: m.user_id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<span>{m.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
|
||||
{/* Agents */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<PickerSection label="Agents">
|
||||
{filteredAgents.map((a) => (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={isSelected("agent", a.id)}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
assignee_type: "agent",
|
||||
assignee_id: a.id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot style={{ width: 10, height: 10 }} />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 &&
|
||||
filteredAgents.length === 0 &&
|
||||
filter && <PickerEmpty />}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
4
apps/web/features/issues/components/pickers/index.ts
Normal file
4
apps/web/features/issues/components/pickers/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
|
||||
export { StatusPicker } from "./status-picker";
|
||||
export { PriorityPicker } from "./priority-picker";
|
||||
export { AssigneePicker } from "./assignee-picker";
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { IssuePriority, UpdateIssueRequest } from "@multica/types";
|
||||
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { PriorityIcon } from "../priority-icon";
|
||||
import { PropertyPicker, PickerItem } from "./property-picker";
|
||||
|
||||
export function PriorityPicker({
|
||||
priority,
|
||||
onUpdate,
|
||||
}: {
|
||||
priority: IssuePriority;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-44"
|
||||
trigger={
|
||||
<>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span>{cfg.label}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{PRIORITY_ORDER.map((p) => {
|
||||
const c = PRIORITY_CONFIG[p];
|
||||
return (
|
||||
<PickerItem
|
||||
key={p}
|
||||
selected={p === priority}
|
||||
onClick={() => {
|
||||
onUpdate({ priority: p });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<PriorityIcon priority={p} />
|
||||
<span>{c.label}</span>
|
||||
</PickerItem>
|
||||
);
|
||||
})}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
133
apps/web/features/issues/components/pickers/property-picker.tsx
Normal file
133
apps/web/features/issues/components/pickers/property-picker.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PropertyPicker — generic Popover shell with optional search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PropertyPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
width = "w-48",
|
||||
align = "end",
|
||||
searchable = false,
|
||||
searchPlaceholder = "Filter...",
|
||||
onSearchChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
trigger: React.ReactNode;
|
||||
width?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(v: boolean) => {
|
||||
onOpenChange(v);
|
||||
if (!v) {
|
||||
setQuery("");
|
||||
onSearchChange?.("");
|
||||
}
|
||||
},
|
||||
[onOpenChange, onSearchChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
|
||||
{trigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} className={`${width} gap-0 p-0`}>
|
||||
{searchable && (
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PickerItem — single selectable row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PickerItem({
|
||||
selected,
|
||||
onClick,
|
||||
hoverClassName,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
hoverClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-2">{children}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PickerSection — group header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PickerSection({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 pt-2 pb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PickerEmpty — no results state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PickerEmpty() {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-[13px] text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { IssueStatus, UpdateIssueRequest } from "@multica/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon } from "../status-icon";
|
||||
import { PropertyPicker, PickerItem } from "./property-picker";
|
||||
|
||||
export function StatusPicker({
|
||||
status,
|
||||
onUpdate,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-44"
|
||||
trigger={
|
||||
<>
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span>{cfg.label}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{ALL_STATUSES.map((s) => {
|
||||
const c = STATUS_CONFIG[s];
|
||||
return (
|
||||
<PickerItem
|
||||
key={s}
|
||||
selected={s === status}
|
||||
hoverClassName={c.hoverBg}
|
||||
onClick={() => {
|
||||
onUpdate({ status: s });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
<span>{c.label}</span>
|
||||
</PickerItem>
|
||||
);
|
||||
})}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
57
apps/web/features/issues/components/priority-icon.tsx
Normal file
57
apps/web/features/issues/components/priority-icon.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { IssuePriority } from "@multica/types";
|
||||
import { PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
|
||||
export function PriorityIcon({
|
||||
priority,
|
||||
className = "",
|
||||
}: {
|
||||
priority: IssuePriority;
|
||||
className?: string;
|
||||
}) {
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
|
||||
// "none" — simple horizontal dashes
|
||||
if (cfg.bars === 0) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 text-muted-foreground shrink-0 ${className}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="8" x2="13" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const isUrgent = priority === "urgent";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 ${cfg.color} shrink-0 ${className}`}
|
||||
fill="currentColor"
|
||||
style={isUrgent ? { animation: "priority-pulse 2s ease-in-out infinite" } : undefined}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={1 + i * 4}
|
||||
width="3"
|
||||
rx="0.5"
|
||||
style={{
|
||||
y: 12 - (i + 1) * 3,
|
||||
height: (i + 1) * 3,
|
||||
opacity: i < cfg.bars ? 1 : 0.2,
|
||||
transition: "y 0.2s ease, height 0.2s ease, opacity 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isUrgent && (
|
||||
<style>{`@keyframes priority-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}`}</style>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
169
apps/web/features/issues/components/status-icon.tsx
Normal file
169
apps/web/features/issues/components/status-icon.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { IssueStatus } from "@multica/types";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CX = 8;
|
||||
const CY = 8;
|
||||
const R = 6;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-status SVG renderers — Linear-style icons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 16 small dots arranged in a ring */
|
||||
function BacklogIcon() {
|
||||
const count = 16;
|
||||
const dotR = 0.65;
|
||||
return (
|
||||
<g>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={CX + R * Math.cos(angle)}
|
||||
cy={CY + R * Math.sin(angle)}
|
||||
r={dotR}
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty circle, solid outline */
|
||||
function TodoIcon() {
|
||||
return (
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + right half filled (D-shape) */
|
||||
function InProgressIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={`M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} Z`}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + 75% pie fill (bottom-left quarter empty) */
|
||||
function InReviewIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={`M${CX},${CY} L${CX},${CY - R} A${R},${R} 0 1,1 ${CX - R},${CY} Z`}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Solid filled circle + white checkmark */
|
||||
function DoneIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle cx={CX} cy={CY} r={R} fill="currentColor" />
|
||||
<path
|
||||
d="M5.5 8.2 L7.2 9.8 L10.5 6.2"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + X inside */
|
||||
function CancelledIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M5.75 5.75 L10.25 10.25 M10.25 5.75 L5.75 10.25"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_RENDERERS: Record<IssueStatus, () => React.ReactNode> = {
|
||||
backlog: BacklogIcon,
|
||||
todo: TodoIcon,
|
||||
in_progress: InProgressIcon,
|
||||
in_review: InReviewIcon,
|
||||
done: DoneIcon,
|
||||
blocked: CancelledIcon, // fallback if backend sends blocked
|
||||
cancelled: CancelledIcon,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function StatusIcon({
|
||||
status,
|
||||
className = "h-4 w-4",
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const Renderer = STATUS_RENDERERS[status];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
className={`${className} ${cfg.iconColor} shrink-0`}
|
||||
>
|
||||
<Renderer />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
2
apps/web/features/issues/config/index.ts
Normal file
2
apps/web/features/issues/config/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";
|
||||
20
apps/web/features/issues/config/priority.ts
Normal file
20
apps/web/features/issues/config/priority.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { IssuePriority } from "@multica/types";
|
||||
|
||||
export const PRIORITY_ORDER: IssuePriority[] = [
|
||||
"urgent",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"none",
|
||||
];
|
||||
|
||||
export const PRIORITY_CONFIG: Record<
|
||||
IssuePriority,
|
||||
{ label: string; bars: number; color: string }
|
||||
> = {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
|
||||
high: { label: "High", bars: 3, color: "text-warning" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning" },
|
||||
low: { label: "Low", bars: 1, color: "text-info" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
|
||||
};
|
||||
32
apps/web/features/issues/config/status.ts
Normal file
32
apps/web/features/issues/config/status.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { IssueStatus } from "@multica/types";
|
||||
|
||||
export const STATUS_ORDER: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
export const ALL_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
export const STATUS_CONFIG: Record<
|
||||
IssueStatus,
|
||||
{ label: string; iconColor: string; hoverBg: string }
|
||||
> = {
|
||||
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
|
||||
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
|
||||
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
|
||||
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
};
|
||||
2
apps/web/features/issues/index.ts
Normal file
2
apps/web/features/issues/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
20
apps/web/features/realtime/hooks.ts
Normal file
20
apps/web/features/realtime/hooks.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { WSEventType } from "@multica/types";
|
||||
import { useWS } from "./provider";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
/**
|
||||
* Hook that subscribes to a WebSocket event and calls the handler.
|
||||
* Automatically unsubscribes on cleanup.
|
||||
*/
|
||||
export function useWSEvent(event: WSEventType, handler: EventHandler) {
|
||||
const { subscribe } = useWS();
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(event, handler);
|
||||
return unsub;
|
||||
}, [event, handler, subscribe]);
|
||||
}
|
||||
2
apps/web/features/realtime/index.ts
Normal file
2
apps/web/features/realtime/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { WSProvider, useWS } from "./provider";
|
||||
export { useWSEvent } from "./hooks";
|
||||
62
apps/web/features/realtime/provider.tsx
Normal file
62
apps/web/features/realtime/provider.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { WSClient } from "@multica/sdk";
|
||||
import type { WSEventType } from "@multica/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
interface WSContextValue {
|
||||
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
|
||||
}
|
||||
|
||||
const WSContext = createContext<WSContextValue | null>(null);
|
||||
|
||||
export function WSProvider({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const wsRef = useRef<WSClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const ws = new WSClient(WS_URL);
|
||||
wsRef.current = ws;
|
||||
ws.connect();
|
||||
|
||||
return () => {
|
||||
ws.disconnect();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(event: WSEventType, handler: EventHandler) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws) return () => {};
|
||||
return ws.on(event, handler);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<WSContext.Provider value={{ subscribe }}>
|
||||
{children}
|
||||
</WSContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWS() {
|
||||
const ctx = useContext(WSContext);
|
||||
if (!ctx) throw new Error("useWS must be used within WSProvider");
|
||||
return ctx;
|
||||
}
|
||||
36
apps/web/features/workspace/hooks.ts
Normal file
36
apps/web/features/workspace/hooks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
export function useActorName() {
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
const getMemberName = (userId: string) => {
|
||||
const m = members.find((m) => m.user_id === userId);
|
||||
return m?.name ?? "Unknown";
|
||||
};
|
||||
|
||||
const getAgentName = (agentId: string) => {
|
||||
const a = agents.find((a) => a.id === agentId);
|
||||
return a?.name ?? "Unknown Agent";
|
||||
};
|
||||
|
||||
const getActorName = (type: string, id: string) => {
|
||||
if (type === "member") return getMemberName(id);
|
||||
if (type === "agent") return getAgentName(id);
|
||||
return "System";
|
||||
};
|
||||
|
||||
const getActorInitials = (type: string, id: string) => {
|
||||
const name = getActorName(type, id);
|
||||
return name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return { getMemberName, getAgentName, getActorName, getActorInitials };
|
||||
}
|
||||
2
apps/web/features/workspace/index.ts
Normal file
2
apps/web/features/workspace/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useWorkspaceStore } from "./store";
|
||||
export { useActorName } from "./hooks";
|
||||
142
apps/web/features/workspace/store.ts
Normal file
142
apps/web/features/workspace/store.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, MemberWithUser, Agent } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface WorkspaceState {
|
||||
workspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
members: MemberWithUser[];
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
hydrateWorkspace: (
|
||||
wsList: Workspace[],
|
||||
preferredWorkspaceId?: string | null,
|
||||
) => Promise<Workspace | null>;
|
||||
switchWorkspace: (workspaceId: string) => Promise<void>;
|
||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
refreshMembers: () => Promise<void>;
|
||||
refreshAgents: () => Promise<void>;
|
||||
createWorkspace: (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}) => Promise<Workspace>;
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
||||
clearWorkspace: () => void;
|
||||
}
|
||||
|
||||
type WorkspaceStore = WorkspaceState & WorkspaceActions;
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
// State
|
||||
workspace: null,
|
||||
workspaces: [],
|
||||
members: [],
|
||||
agents: [],
|
||||
|
||||
// Actions
|
||||
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
|
||||
set({ workspaces: wsList });
|
||||
|
||||
const nextWorkspace =
|
||||
(preferredWorkspaceId
|
||||
? wsList.find((item) => item.id === preferredWorkspaceId)
|
||||
: null) ??
|
||||
wsList[0] ??
|
||||
null;
|
||||
|
||||
if (!nextWorkspace) {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, members: [], agents: [] });
|
||||
return null;
|
||||
}
|
||||
|
||||
api.setWorkspaceId(nextWorkspace.id);
|
||||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
|
||||
const [nextMembers, nextAgents] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id }),
|
||||
]);
|
||||
set({ members: nextMembers, agents: nextAgents });
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: async (workspaceId) => {
|
||||
const { workspaces, hydrateWorkspace } = get();
|
||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
await hydrateWorkspace(workspaces, ws.id);
|
||||
},
|
||||
|
||||
refreshWorkspaces: async () => {
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
},
|
||||
|
||||
refreshMembers: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
},
|
||||
|
||||
refreshAgents: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id });
|
||||
set({ agents });
|
||||
},
|
||||
|
||||
createWorkspace: async (data) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||
return ws;
|
||||
},
|
||||
|
||||
updateWorkspace: (ws) => {
|
||||
set((state) => ({
|
||||
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
||||
workspaces: state.workspaces.map((item) =>
|
||||
item.id === ws.id ? ws : item,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
leaveWorkspace: async (workspaceId) => {
|
||||
await api.leaveWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
deleteWorkspace: async (workspaceId) => {
|
||||
await api.deleteWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, workspaces: [], members: [], agents: [] });
|
||||
},
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue