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,106 @@
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
import { EntryStatus } from "@mosaic/shared";
import Link from "next/link";
import { FileText, Eye, Users, Lock } from "lucide-react";
interface EntryCardProps {
entry: KnowledgeEntryWithTags;
}
const statusConfig = {
[EntryStatus.DRAFT]: {
label: "Draft",
className: "bg-gray-100 text-gray-700",
icon: "📝",
},
[EntryStatus.PUBLISHED]: {
label: "Published",
className: "bg-green-100 text-green-700",
icon: "✅",
},
[EntryStatus.ARCHIVED]: {
label: "Archived",
className: "bg-amber-100 text-amber-700",
icon: "📦",
},
};
const visibilityIcons = {
PRIVATE: <Lock className="w-3 h-3" />,
WORKSPACE: <Users className="w-3 h-3" />,
PUBLIC: <Eye className="w-3 h-3" />,
};
export function EntryCard({ entry }: EntryCardProps) {
const statusInfo = statusConfig[entry.status];
const visibilityIcon = visibilityIcons[entry.visibility];
return (
<Link
href={`/knowledge/${entry.slug}`}
className="block bg-white p-5 rounded-lg shadow-sm border border-gray-200 hover:shadow-md hover:border-blue-300 transition-all"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
<FileText className="w-5 h-5 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
{/* Title */}
<h3 className="font-semibold text-gray-900 mb-2 text-lg hover:text-blue-600 transition-colors">
{entry.title}
</h3>
{/* Summary */}
{entry.summary && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{entry.summary}
</p>
)}
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{entry.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
style={{
backgroundColor: tag.color ? `${tag.color}20` : "#E5E7EB",
color: tag.color || "#6B7280",
}}
>
{tag.name}
</span>
))}
</div>
)}
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
{/* Status */}
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full ${statusInfo.className}`}>
<span>{statusInfo.icon}</span>
<span>{statusInfo.label}</span>
</span>
{/* Visibility */}
<span className="inline-flex items-center gap-1">
{visibilityIcon}
<span className="capitalize">{entry.visibility.toLowerCase()}</span>
</span>
{/* Updated date */}
<span>
Updated {new Date(entry.updatedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</div>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import React, { useState } from "react";
interface EntryEditorProps {
content: string;
onChange: (content: string) => void;
}
/**
* EntryEditor - Markdown editor with live preview
*/
export function EntryEditor({ content, onChange }: EntryEditorProps): JSX.Element {
const [showPreview, setShowPreview] = useState(false);
return (
<div className="entry-editor">
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Content (Markdown)
</label>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showPreview ? "Edit" : "Preview"}
</button>
</div>
{showPreview ? (
<div className="prose prose-sm max-w-none dark:prose-invert p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 min-h-[300px]">
<div className="whitespace-pre-wrap">{content}</div>
</div>
) : (
<textarea
value={content}
onChange={(e) => onChange(e.target.value)}
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Write your content here... (Markdown supported)"
/>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Supports Markdown formatting. Use the Preview button to see how it will look.
</p>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { EntryStatus } from "@mosaic/shared";
import type { KnowledgeTag } from "@mosaic/shared";
import { Search, Filter } from "lucide-react";
interface EntryFiltersProps {
selectedStatus: EntryStatus | "all";
selectedTag: string | "all";
searchQuery: string;
sortBy: "updatedAt" | "createdAt" | "title";
sortOrder: "asc" | "desc";
tags: KnowledgeTag[];
onStatusChange: (status: EntryStatus | "all") => void;
onTagChange: (tag: string | "all") => void;
onSearchChange: (query: string) => void;
onSortChange: (sortBy: "updatedAt" | "createdAt" | "title", sortOrder: "asc" | "desc") => void;
}
export function EntryFilters({
selectedStatus,
selectedTag,
searchQuery,
sortBy,
sortOrder,
tags,
onStatusChange,
onTagChange,
onSearchChange,
onSortChange,
}: EntryFiltersProps) {
const statusOptions: Array<{ value: EntryStatus | "all"; label: string }> = [
{ value: "all", label: "All Status" },
{ value: EntryStatus.DRAFT, label: "Draft" },
{ value: EntryStatus.PUBLISHED, label: "Published" },
{ value: EntryStatus.ARCHIVED, label: "Archived" },
];
const sortOptions: Array<{
value: "updatedAt" | "createdAt" | "title";
label: string;
}> = [
{ value: "updatedAt", label: "Last Updated" },
{ value: "createdAt", label: "Created Date" },
{ value: "title", label: "Title" },
];
return (
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 mb-6 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search entries..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Filters row */}
<div className="flex flex-wrap gap-3">
{/* Status filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as EntryStatus | "all")}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Tag filter */}
<div>
<select
value={selectedTag}
onChange={(e) => onTagChange(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">All Tags</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.slug}>
{tag.name}
</option>
))}
</select>
</div>
{/* Sort controls */}
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-gray-600">Sort by:</span>
<select
value={sortBy}
onChange={(e) =>
onSortChange(
e.target.value as "updatedAt" | "createdAt" | "title",
sortOrder
)
}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
onClick={() => onSortChange(sortBy, sortOrder === "asc" ? "desc" : "asc")}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition-colors"
title={sortOrder === "asc" ? "Sort descending" : "Sort ascending"}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
import { EntryCard } from "./EntryCard";
import { BookOpen } from "lucide-react";
interface EntryListProps {
entries: KnowledgeEntryWithTags[];
isLoading: boolean;
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function EntryList({
entries,
isLoading,
currentPage,
totalPages,
onPageChange,
}: EntryListProps) {
if (isLoading) {
return (
<div className="flex justify-center items-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading entries...</span>
</div>
);
}
if (!entries || entries.length === 0) {
return (
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200">
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-lg text-gray-700 font-medium">No entries found</p>
<p className="text-sm text-gray-500 mt-2">
Try adjusting your filters or create a new entry
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Entry cards */}
<div className="space-y-4">
{entries.map((entry) => (
<EntryCard key={entry.id} entry={entry} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-6">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
// Show first, last, current, and pages around current
const shouldShow =
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= 1;
// Show ellipsis
const showEllipsisBefore = page === currentPage - 2 && currentPage > 3;
const showEllipsisAfter = page === currentPage + 2 && currentPage < totalPages - 2;
if (!shouldShow && !showEllipsisBefore && !showEllipsisAfter) {
return null;
}
if (showEllipsisBefore || showEllipsisAfter) {
return (
<span key={`ellipsis-${page}`} className="px-2 text-gray-500">
...
</span>
);
}
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{page}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
{/* Results info */}
<div className="text-center text-sm text-gray-500">
Page {currentPage} of {totalPages} ({entries.length} entries)
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import React from "react";
import type { KnowledgeTag } from "@mosaic/shared";
import { EntryStatus, Visibility } from "@mosaic/shared";
interface EntryMetadataProps {
title: string;
status: EntryStatus;
visibility: Visibility;
selectedTags: string[];
availableTags: KnowledgeTag[];
onTitleChange: (title: string) => void;
onStatusChange: (status: EntryStatus) => void;
onVisibilityChange: (visibility: Visibility) => void;
onTagsChange: (tags: string[]) => void;
}
/**
* EntryMetadata - Title, tags, status, and visibility controls
*/
export function EntryMetadata({
title,
status,
visibility,
selectedTags,
availableTags,
onTitleChange,
onStatusChange,
onVisibilityChange,
onTagsChange,
}: EntryMetadataProps): JSX.Element {
const handleTagToggle = (tagId: string): void => {
if (selectedTags.includes(tagId)) {
onTagsChange(selectedTags.filter((id) => id !== tagId));
} else {
onTagsChange([...selectedTags, tagId]);
}
};
return (
<div className="entry-metadata space-y-4">
{/* Title */}
<div>
<label
htmlFor="entry-title"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Title
</label>
<input
id="entry-title"
type="text"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Entry title..."
required
/>
</div>
{/* Status and Visibility Row */}
<div className="grid grid-cols-2 gap-4">
{/* Status */}
<div>
<label
htmlFor="entry-status"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Status
</label>
<select
id="entry-status"
value={status}
onChange={(e) => onStatusChange(e.target.value as EntryStatus)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value={EntryStatus.DRAFT}>Draft</option>
<option value={EntryStatus.PUBLISHED}>Published</option>
<option value={EntryStatus.ARCHIVED}>Archived</option>
</select>
</div>
{/* Visibility */}
<div>
<label
htmlFor="entry-visibility"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Visibility
</label>
<select
id="entry-visibility"
value={visibility}
onChange={(e) => onVisibilityChange(e.target.value as Visibility)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value={Visibility.PRIVATE}>Private</option>
<option value={Visibility.WORKSPACE}>Workspace</option>
<option value={Visibility.PUBLIC}>Public</option>
</select>
</div>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
</label>
<div className="flex flex-wrap gap-2">
{availableTags.length > 0 ? (
availableTags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => handleTagToggle(tag.id)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
isSelected
? "bg-blue-600 text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
style={
isSelected && tag.color
? { backgroundColor: tag.color }
: undefined
}
>
{tag.name}
</button>
);
})
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
No tags available. Create tags first.
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import React from "react";
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
interface EntryViewerProps {
entry: KnowledgeEntryWithTags;
}
/**
* EntryViewer - Displays rendered markdown content
*/
export function EntryViewer({ entry }: EntryViewerProps): JSX.Element {
return (
<div className="entry-viewer">
<div className="entry-content">
{entry.contentHtml ? (
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: entry.contentHtml }}
/>
) : (
<div className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
{entry.content}
</div>
)}
</div>
{entry.summary && (
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Summary
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{entry.summary}</p>
</div>
)}
</div>
);
}