polish(ui): inline editing, skeleton loading, toast feedback, empty states
- Issue detail: click-to-edit title (Input) and description (Textarea) - Issue detail: acceptance criteria and context refs always addable (even when empty) - Comment: optimistic create with temp ID + opacity, rollback on error - Comment: timestamp hover tooltip shows full date - Issues page: skeleton loading state, empty column text, "No matching issues" with clear filters - Inbox page: skeleton loading state for two-panel layout - Settings: replace raw textarea with shadcn Textarea, replace inline saved/error text with toast - Settings: member operations use toast feedback (add/remove/role change) - Sidebar: workspace create error shows toast instead of silent console.error Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f71957608
commit
5c583ccb82
6 changed files with 272 additions and 141 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[
|
||||
{ "id": "infra-event-bus-ws", "status": "done", "name": "Infrastructure: Event Bus + WS Isolation + Global Store" },
|
||||
{ "id": "issue-board-polish", "status": "in_progress", "name": "Issue Board & Detail Polish" },
|
||||
{ "id": "issue-board-polish", "status": "done", "name": "Issue Board & Detail Polish" },
|
||||
{ "id": "workspace-permissions", "status": "done", "name": "Workspace & Permissions" },
|
||||
{ "id": "inbox-notifications", "status": "done", "name": "Inbox & Notifications" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Plus,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -106,6 +107,7 @@ export function AppSidebar() {
|
|||
await switchWorkspace(ws.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to create workspace:", err);
|
||||
toast.error("Failed to create workspace");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -226,8 +227,28 @@ export default function InboxPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex h-full">
|
||||
<div className="w-80 shrink-0 border-r">
|
||||
<div className="flex h-12 items-center border-b px-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 px-4 py-3">
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-6">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="mt-4 h-4 w-32" />
|
||||
<Skeleton className="mt-6 h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
|
||||
import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
|
||||
|
|
@ -163,40 +170,53 @@ function AcceptanceCriteriaEditor({
|
|||
onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
if (criteria.length === 0 && !newItem) {
|
||||
return null;
|
||||
}
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Acceptance Criteria</h3>
|
||||
<div className="space-y-1">
|
||||
{criteria.map((item, i) => (
|
||||
<div key={i} className="group flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 text-muted-foreground">•</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeItem(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addItem(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
placeholder="Add criteria..."
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
{criteria.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{criteria.map((item, i) => (
|
||||
<div key={i} className="group flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 text-muted-foreground">•</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeItem(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(criteria.length > 0 || adding) ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addItem(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus={adding}
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onBlur={() => { if (!newItem.trim()) setAdding(false); }}
|
||||
placeholder="Add criteria..."
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Add acceptance criteria
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -224,48 +244,61 @@ function ContextRefsEditor({
|
|||
onUpdate({ context_refs: refs.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
if (refs.length === 0 && !newRef) {
|
||||
return null;
|
||||
}
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Context References</h3>
|
||||
<div className="space-y-1">
|
||||
{refs.map((ref, i) => (
|
||||
<div key={i} className="group flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{isUrl(ref) ? (
|
||||
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
|
||||
{ref}
|
||||
</a>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{ref}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeRef(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addRef(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
value={newRef}
|
||||
onChange={(e) => setNewRef(e.target.value)}
|
||||
placeholder="Add reference URL..."
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
{refs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{refs.map((ref, i) => (
|
||||
<div key={i} className="group flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{isUrl(ref) ? (
|
||||
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
|
||||
{ref}
|
||||
</a>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{ref}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeRef(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(refs.length > 0 || adding) ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addRef(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus={adding}
|
||||
value={newRef}
|
||||
onChange={(e) => setNewRef(e.target.value)}
|
||||
onBlur={() => { if (!newRef.trim()) setAdding(false); }}
|
||||
placeholder="Add reference URL..."
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Add context reference
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -291,6 +324,10 @@ export default function IssueDetailPage({
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [editingDesc, setEditingDesc] = useState(false);
|
||||
const [descDraft, setDescDraft] = useState("");
|
||||
|
||||
// Watch the global issue store for real-time updates from other users/agents
|
||||
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
|
||||
|
|
@ -316,14 +353,28 @@ export default function IssueDetailPage({
|
|||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!commentText.trim() || submitting) return;
|
||||
if (!commentText.trim() || submitting || !user) return;
|
||||
const content = commentText.trim();
|
||||
const tempId = "temp-" + Date.now();
|
||||
const tempComment: Comment = {
|
||||
id: tempId,
|
||||
issue_id: id,
|
||||
author_type: "member",
|
||||
author_id: user.id,
|
||||
content,
|
||||
type: "comment",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setComments((prev) => [...prev, tempComment]);
|
||||
setCommentText("");
|
||||
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);
|
||||
const comment = await api.createComment(id, content);
|
||||
setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c)));
|
||||
} catch {
|
||||
setComments((prev) => prev.filter((c) => c.id !== tempId));
|
||||
toast.error("Failed to send comment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -477,28 +528,72 @@ export default function IssueDetailPage({
|
|||
<div className="mx-auto w-full max-w-3xl px-8 py-8">
|
||||
<div className="mb-1 text-[13px] text-muted-foreground">{issue.id.slice(0, 8)}</div>
|
||||
|
||||
<h1 className="text-xl font-semibold leading-snug tracking-tight">
|
||||
{issue.title}
|
||||
</h1>
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
|
||||
setEditingTitle(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
className="text-xl font-semibold leading-snug tracking-tight"
|
||||
/>
|
||||
) : (
|
||||
<h1
|
||||
className="text-xl font-semibold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
|
||||
onClick={() => { setTitleDraft(issue.title); setEditingTitle(true); }}
|
||||
>
|
||||
{issue.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{issue.description && (
|
||||
<div className="mt-5 text-[14px] leading-[1.7] text-foreground/85 whitespace-pre-wrap">
|
||||
{issue.description}
|
||||
{editingDesc ? (
|
||||
<Textarea
|
||||
autoFocus
|
||||
value={descDraft}
|
||||
onChange={(e) => setDescDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
handleUpdateField({ description: descDraft.trim() || undefined });
|
||||
setEditingDesc(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditingDesc(false);
|
||||
}}
|
||||
rows={4}
|
||||
className="mt-5 text-[14px] leading-[1.7] resize-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="mt-5 text-[14px] leading-[1.7] whitespace-pre-wrap cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
|
||||
onClick={() => { setDescDraft(issue.description || ""); setEditingDesc(true); }}
|
||||
>
|
||||
{issue.description ? (
|
||||
<span className="text-foreground/85">{issue.description}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Add description...</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(issue.acceptance_criteria.length > 0 || issue.context_refs.length > 0) && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={issue.acceptance_criteria}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
<ContextRefsEditor
|
||||
refs={issue.context_refs}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 mt-4">
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={issue.acceptance_criteria}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
<ContextRefsEditor
|
||||
refs={issue.context_refs}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
|
|
@ -510,7 +605,7 @@ export default function IssueDetailPage({
|
|||
{comments.map((comment) => {
|
||||
const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
|
||||
return (
|
||||
<div key={comment.id} className="group relative py-3">
|
||||
<div key={comment.id} className={`group relative py-3${comment.id.startsWith("temp-") ? " opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={comment.author_type}
|
||||
|
|
@ -522,9 +617,20 @@ export default function IssueDetailPage({
|
|||
<span className="text-[13px] font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-[12px] text-muted-foreground cursor-default">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isOwn && (
|
||||
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
List,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
|
|
@ -159,6 +160,9 @@ function DroppableColumn({
|
|||
{issues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
{issues.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">No issues</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -503,8 +507,20 @@ export default function IssuesPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 gap-3 overflow-x-auto p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -576,7 +592,19 @@ export default function IssuesPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{view === "board" ? (
|
||||
{issues.length === 0 && !loading ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<p>No matching issues</p>
|
||||
{(filterStatus || filterPriority) && (
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => { setFilterStatus(""); setFilterPriority(""); }}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "board" ? (
|
||||
<BoardView issues={issues} onMoveIssue={handleMoveIssue} />
|
||||
) : (
|
||||
<ListView issues={issues} />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
|
|
@ -108,16 +109,11 @@ export default function SettingsPage() {
|
|||
const [profileName, setProfileName] = useState(user?.name ?? "");
|
||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [profileSaved, setProfileSaved] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [memberActionId, setMemberActionId] = useState<string | null>(null);
|
||||
const [workspaceError, setWorkspaceError] = useState("");
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const [memberError, setMemberError] = useState("");
|
||||
const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
|
||||
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
const isOwner = currentMember?.role === "owner";
|
||||
|
|
@ -136,7 +132,6 @@ export default function SettingsPage() {
|
|||
const handleSave = async () => {
|
||||
if (!workspace) return;
|
||||
setSaving(true);
|
||||
setWorkspaceError("");
|
||||
try {
|
||||
const updated = await api.updateWorkspace(workspace.id, {
|
||||
name,
|
||||
|
|
@ -144,10 +139,9 @@ export default function SettingsPage() {
|
|||
context,
|
||||
});
|
||||
updateWorkspace(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
toast.success("Workspace settings saved");
|
||||
} catch (e) {
|
||||
setWorkspaceError(e instanceof Error ? e.message : "Failed to update workspace");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -155,17 +149,15 @@ export default function SettingsPage() {
|
|||
|
||||
const handleProfileSave = async () => {
|
||||
setProfileSaving(true);
|
||||
setProfileError("");
|
||||
try {
|
||||
const updated = await api.updateMe({
|
||||
name: profileName,
|
||||
avatar_url: avatarUrl || undefined,
|
||||
});
|
||||
setUser(updated);
|
||||
setProfileSaved(true);
|
||||
setTimeout(() => setProfileSaved(false), 2000);
|
||||
toast.success("Profile updated");
|
||||
} catch (e) {
|
||||
setProfileError(e instanceof Error ? e.message : "Failed to update profile");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update profile");
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
|
|
@ -174,7 +166,6 @@ export default function SettingsPage() {
|
|||
const handleAddMember = async () => {
|
||||
if (!workspace) return;
|
||||
setInviteLoading(true);
|
||||
setMemberError("");
|
||||
try {
|
||||
await api.createMember(workspace.id, {
|
||||
email: inviteEmail,
|
||||
|
|
@ -183,8 +174,9 @@ export default function SettingsPage() {
|
|||
setInviteEmail("");
|
||||
setInviteRole("member");
|
||||
await refreshMembers();
|
||||
toast.success("Member added");
|
||||
} catch (e) {
|
||||
setMemberError(e instanceof Error ? e.message : "Failed to add member");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add member");
|
||||
} finally {
|
||||
setInviteLoading(false);
|
||||
}
|
||||
|
|
@ -193,12 +185,12 @@ export default function SettingsPage() {
|
|||
const handleRoleChange = async (memberId: string, role: MemberRole) => {
|
||||
if (!workspace) return;
|
||||
setMemberActionId(memberId);
|
||||
setMemberError("");
|
||||
try {
|
||||
await api.updateMember(workspace.id, memberId, { role });
|
||||
await refreshMembers();
|
||||
toast.success("Role updated");
|
||||
} catch (e) {
|
||||
setMemberError(e instanceof Error ? e.message : "Failed to update member");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update member");
|
||||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
|
|
@ -209,12 +201,12 @@ export default function SettingsPage() {
|
|||
if (!window.confirm(`Remove ${member.name} from ${workspace.name}?`)) return;
|
||||
|
||||
setMemberActionId(member.id);
|
||||
setMemberError("");
|
||||
try {
|
||||
await api.deleteMember(workspace.id, member.id);
|
||||
await refreshMembers();
|
||||
toast.success("Member removed");
|
||||
} catch (e) {
|
||||
setMemberError(e instanceof Error ? e.message : "Failed to remove member");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
|
|
@ -225,11 +217,10 @@ export default function SettingsPage() {
|
|||
if (!window.confirm(`Leave ${workspace.name}?`)) return;
|
||||
|
||||
setMemberActionId("leave");
|
||||
setMemberError("");
|
||||
try {
|
||||
await leaveWorkspace(workspace.id);
|
||||
} catch (e) {
|
||||
setMemberError(e instanceof Error ? e.message : "Failed to leave workspace");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
|
||||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
|
|
@ -240,11 +231,10 @@ export default function SettingsPage() {
|
|||
if (!window.confirm(`Delete ${workspace.name}? This cannot be undone.`)) return;
|
||||
|
||||
setMemberActionId("delete-workspace");
|
||||
setMemberError("");
|
||||
try {
|
||||
await deleteWorkspace(workspace.id);
|
||||
} catch (e) {
|
||||
setMemberError(e instanceof Error ? e.message : "Failed to delete workspace");
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
|
|
@ -290,13 +280,7 @@ export default function SettingsPage() {
|
|||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{profileError && (
|
||||
<p className="text-xs text-destructive">{profileError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
{profileSaved && (
|
||||
<span className="text-xs text-success">Saved!</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleProfileSave}
|
||||
|
|
@ -346,12 +330,12 @@ export default function SettingsPage() {
|
|||
<Label className="text-xs text-muted-foreground">
|
||||
Context
|
||||
</Label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={4}
|
||||
disabled={!canManageWorkspace}
|
||||
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 resize-none"
|
||||
className="mt-1 resize-none"
|
||||
placeholder="Background information and context for AI agents working in this workspace"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -364,12 +348,6 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
{workspaceError && (
|
||||
<span className="text-xs text-destructive">{workspaceError}</span>
|
||||
)}
|
||||
{saved && (
|
||||
<span className="text-xs text-success">Saved!</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
|
|
@ -398,10 +376,6 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{memberError && (
|
||||
<p className="text-sm text-destructive">{memberError}</p>
|
||||
)}
|
||||
|
||||
{canManageWorkspace && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue