feat(web): add workspace management UI (M2 #12)

- Create workspace listing page at /settings/workspaces
  - List all user workspaces with role badges
  - Create new workspace functionality
  - Display member count per workspace

- Create workspace detail page at /settings/workspaces/[id]
  - Workspace settings (name, ID, created date)
  - Member management with role editing
  - Invite member functionality
  - Delete workspace (owner only)

- Add workspace components:
  - WorkspaceCard: Display workspace info with role badge
  - WorkspaceSettings: Edit workspace settings and delete
  - MemberList: Display and manage workspace members
  - InviteMember: Send invitations with role selection

- Add WorkspaceMemberWithUser type to shared package
- Follow existing app patterns for styling and structure
- Use mock data (ready for API integration)
This commit is contained in:
Jason Woltje
2026-01-29 16:59:26 -06:00
parent 287a0e2556
commit 5291fece26
43 changed files with 4152 additions and 99 deletions

View File

@@ -0,0 +1,168 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { EntryStatus, Visibility, type KnowledgeTag } from "@mosaic/shared";
import { EntryEditor } from "@/components/knowledge/EntryEditor";
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
import { createEntry, fetchTags } from "@/lib/api/knowledge";
/**
* New Knowledge Entry Page
* Form for creating a new knowledge entry
*/
export default function NewEntryPage(): JSX.Element {
const router = useRouter();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [status, setStatus] = useState<EntryStatus>(EntryStatus.DRAFT);
const [visibility, setVisibility] = useState<Visibility>(Visibility.WORKSPACE);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Load available tags
useEffect(() => {
async function loadTags(): Promise<void> {
try {
const tags = await fetchTags();
setAvailableTags(tags);
} catch (err) {
console.error("Failed to load tags:", err);
}
}
void loadTags();
}, []);
// Track unsaved changes
useEffect(() => {
setHasUnsavedChanges(title.length > 0 || content.length > 0);
}, [title, content]);
// Warn before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent): string => {
if (hasUnsavedChanges) {
e.preventDefault();
return "You have unsaved changes. Are you sure you want to leave?";
}
return "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasUnsavedChanges]);
// Cmd+S / Ctrl+S to save
const handleSave = useCallback(async (): Promise<void> => {
if (isSubmitting || !title.trim() || !content.trim()) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const entry = await createEntry({
title: title.trim(),
content: content.trim(),
status,
visibility,
tags: selectedTags,
});
setHasUnsavedChanges(false);
router.push(`/knowledge/${entry.slug}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create entry");
setIsSubmitting(false);
}
}, [title, content, status, visibility, selectedTags, isSubmitting, router]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
void handleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSave]);
const handleCancel = (): void => {
if (
!hasUnsavedChanges ||
confirm("You have unsaved changes. Are you sure you want to cancel?")
) {
router.push("/knowledge");
}
};
const handleSubmit = (e: React.FormEvent): void => {
e.preventDefault();
void handleSave();
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
New Knowledge Entry
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Create a new entry in your knowledge base
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<EntryMetadata
title={title}
status={status}
visibility={visibility}
selectedTags={selectedTags}
availableTags={availableTags}
onTitleChange={setTitle}
onStatusChange={setStatus}
onVisibilityChange={setVisibility}
onTagsChange={setSelectedTags}
/>
<EntryEditor content={content} onChange={setContent} />
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !title.trim() || !content.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "Creating..." : "Create Entry"}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
Press <kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">Cmd+S</kbd>{" "}
or <kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">Ctrl+S</kbd> to
save
</p>
</form>
</div>
);
}

View File

@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { WorkspaceSettings } from "@/components/workspace/WorkspaceSettings";
import { MemberList } from "@/components/workspace/MemberList";
import { InviteMember } from "@/components/workspace/InviteMember";
import { WorkspaceMemberRole } from "@mosaic/shared";
import type { Workspace, WorkspaceMemberWithUser } from "@mosaic/shared";
import Link from "next/link";
interface WorkspaceDetailPageProps {
params: {
id: string;
};
}
// Mock data - TODO: Replace with real API calls
const mockWorkspace: Workspace = {
id: "ws-1",
name: "Personal Workspace",
ownerId: "user-1",
settings: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
};
const mockMembers: WorkspaceMemberWithUser[] = [
{
workspaceId: "ws-1",
userId: "user-1",
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2024-01-15"),
user: {
id: "user-1",
email: "owner@example.com",
name: "John Doe",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
},
},
{
workspaceId: "ws-1",
userId: "user-2",
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2024-01-16"),
user: {
id: "user-2",
email: "admin@example.com",
name: "Jane Smith",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2024-01-16"),
updatedAt: new Date("2024-01-16"),
},
},
{
workspaceId: "ws-1",
userId: "user-3",
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2024-01-17"),
user: {
id: "user-3",
email: "member@example.com",
name: "Bob Johnson",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2024-01-17"),
updatedAt: new Date("2024-01-17"),
},
},
];
export default function WorkspaceDetailPage({ params }: WorkspaceDetailPageProps) {
const router = useRouter();
const [workspace, setWorkspace] = useState<Workspace>(mockWorkspace);
const [members, setMembers] = useState<WorkspaceMemberWithUser[]>(mockMembers);
const currentUserId = "user-1"; // TODO: Get from auth context
const currentUserRole = WorkspaceMemberRole.OWNER; // TODO: Get from API
const canInvite =
currentUserRole === WorkspaceMemberRole.OWNER ||
currentUserRole === WorkspaceMemberRole.ADMIN;
const handleUpdateWorkspace = async (name: string) => {
// TODO: Replace with real API call
console.log("Updating workspace:", { id: params.id, name });
await new Promise((resolve) => setTimeout(resolve, 500));
setWorkspace({ ...workspace, name, updatedAt: new Date() });
};
const handleDeleteWorkspace = async () => {
// TODO: Replace with real API call
console.log("Deleting workspace:", params.id);
await new Promise((resolve) => setTimeout(resolve, 1000));
router.push("/settings/workspaces");
};
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole) => {
// TODO: Replace with real API call
console.log("Changing role:", { userId, newRole });
await new Promise((resolve) => setTimeout(resolve, 500));
setMembers(
members.map((member) =>
member.userId === userId ? { ...member, role: newRole } : member
)
);
};
const handleRemoveMember = async (userId: string) => {
// TODO: Replace with real API call
console.log("Removing member:", userId);
await new Promise((resolve) => setTimeout(resolve, 500));
setMembers(members.filter((member) => member.userId !== userId));
};
const handleInviteMember = async (email: string, role: WorkspaceMemberRole) => {
// TODO: Replace with real API call
console.log("Inviting member:", { email, role, workspaceId: params.id });
await new Promise((resolve) => setTimeout(resolve, 1000));
// In real implementation, this would send an invitation email
};
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">{workspace.name}</h1>
<Link
href="/settings/workspaces"
className="text-sm text-blue-600 hover:text-blue-700"
>
Back to Workspaces
</Link>
</div>
<p className="text-gray-600">
Manage workspace settings and team members
</p>
</div>
<div className="space-y-6">
{/* Workspace Settings */}
<WorkspaceSettings
workspace={workspace}
userRole={currentUserRole}
onUpdate={handleUpdateWorkspace}
onDelete={handleDeleteWorkspace}
/>
{/* Members Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="lg:col-span-2">
<MemberList
members={members}
currentUserId={currentUserId}
currentUserRole={currentUserRole}
workspaceOwnerId={workspace.ownerId}
onRoleChange={handleRoleChange}
onRemove={handleRemoveMember}
/>
</div>
{/* Invite Member */}
{canInvite && (
<div className="lg:col-span-2">
<InviteMember onInvite={handleInviteMember} />
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { WorkspaceCard } from "@/components/workspace/WorkspaceCard";
import { WorkspaceMemberRole } from "@mosaic/shared";
import Link from "next/link";
// Mock data - TODO: Replace with real API calls
const mockWorkspaces = [
{
id: "ws-1",
name: "Personal Workspace",
ownerId: "user-1",
settings: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
},
{
id: "ws-2",
name: "Team Alpha",
ownerId: "user-2",
settings: {},
createdAt: new Date("2024-01-20"),
updatedAt: new Date("2024-01-20"),
},
];
const mockMemberships = [
{ workspaceId: "ws-1", role: WorkspaceMemberRole.OWNER, memberCount: 1 },
{ workspaceId: "ws-2", role: WorkspaceMemberRole.MEMBER, memberCount: 5 },
];
export default function WorkspacesPage() {
const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState("");
// TODO: Replace with real API call
const workspacesWithRoles = mockWorkspaces.map((workspace) => {
const membership = mockMemberships.find((m) => m.workspaceId === workspace.id);
return {
...workspace,
userRole: membership?.role || WorkspaceMemberRole.GUEST,
memberCount: membership?.memberCount || 0,
};
});
const handleCreateWorkspace = async (e: React.FormEvent) => {
e.preventDefault();
if (!newWorkspaceName.trim()) return;
setIsCreating(true);
try {
// TODO: Replace with real API call
console.log("Creating workspace:", newWorkspaceName);
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
alert(`Workspace "${newWorkspaceName}" created successfully!`);
setNewWorkspaceName("");
} catch (error) {
console.error("Failed to create workspace:", error);
alert("Failed to create workspace");
} finally {
setIsCreating(false);
}
};
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">Workspaces</h1>
<Link
href="/settings"
className="text-sm text-blue-600 hover:text-blue-700"
>
Back to Settings
</Link>
</div>
<p className="text-gray-600">
Manage your workspaces and collaborate with your team
</p>
</div>
{/* Create New Workspace */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Create New Workspace
</h2>
<form onSubmit={handleCreateWorkspace} className="flex gap-3">
<input
type="text"
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
placeholder="Enter workspace name..."
disabled={isCreating}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
/>
<button
type="submit"
disabled={isCreating || !newWorkspaceName.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isCreating ? "Creating..." : "Create Workspace"}
</button>
</form>
</div>
{/* Workspace List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
Your Workspaces ({workspacesWithRoles.length})
</h2>
{workspacesWithRoles.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No workspaces yet
</h3>
<p className="text-gray-600">
Create your first workspace to get started
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{workspacesWithRoles.map((workspace) => (
<WorkspaceCard
key={workspace.id}
workspace={workspace}
userRole={workspace.userRole}
memberCount={workspace.memberCount}
/>
))}
</div>
)}
</div>
</main>
);
}