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:
106
apps/web/src/components/knowledge/EntryCard.tsx
Normal file
106
apps/web/src/components/knowledge/EntryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/components/knowledge/EntryEditor.tsx
Normal file
49
apps/web/src/components/knowledge/EntryEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/knowledge/EntryFilters.tsx
Normal file
125
apps/web/src/components/knowledge/EntryFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/components/knowledge/EntryList.tsx
Normal file
117
apps/web/src/components/knowledge/EntryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
apps/web/src/components/knowledge/EntryMetadata.tsx
Normal file
143
apps/web/src/components/knowledge/EntryMetadata.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/components/knowledge/EntryViewer.tsx
Normal file
39
apps/web/src/components/knowledge/EntryViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/team/TeamCard.tsx
Normal file
32
apps/web/src/components/team/TeamCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Team } from "@mosaic/shared";
|
||||
import { Card, CardHeader, CardContent } from "@mosaic/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
interface TeamCardProps {
|
||||
team: Team;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function TeamCard({ team, workspaceId }: TeamCardProps) {
|
||||
return (
|
||||
<Link href={`/settings/workspaces/${workspaceId}/teams/${team.id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{team.name}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{team.description ? (
|
||||
<p className="text-sm text-gray-600">{team.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic">No description</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Created {new Date(team.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/components/team/TeamSettings.tsx
Normal file
139
apps/web/src/components/team/TeamSettings.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Team } from "@mosaic/shared";
|
||||
import { Card, CardHeader, CardContent, CardFooter, Button, Input, Textarea } from "@mosaic/ui";
|
||||
|
||||
interface TeamSettingsProps {
|
||||
team: Team;
|
||||
workspaceId: string;
|
||||
onUpdate: (data: { name?: string; description?: string }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function TeamSettings({ team, workspaceId, onUpdate, onDelete }: TeamSettingsProps) {
|
||||
const [name, setName] = useState(team.name);
|
||||
const [description, setDescription] = useState(team.description || "");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const hasChanges = name !== team.name || description !== (team.description || "");
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdate({
|
||||
name: name !== team.name ? name : undefined,
|
||||
description: description !== (team.description || "") ? description : undefined,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update team:", error);
|
||||
alert("Failed to update team. Please try again.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setName(team.name);
|
||||
setDescription(team.description || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete team:", error);
|
||||
alert("Failed to delete team. Please try again.");
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Team Settings</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Team Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
placeholder="Enter team name"
|
||||
fullWidth
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
placeholder="Enter team description (optional)"
|
||||
fullWidth
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex gap-2">
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving || !name.trim()}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!showDeleteConfirm ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Delete Team
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-sm text-gray-600 self-center">
|
||||
Are you sure?
|
||||
</span>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Confirm Delete"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/components/workspace/InviteMember.tsx
Normal file
121
apps/web/src/components/workspace/InviteMember.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
|
||||
interface InviteMemberProps {
|
||||
onInvite: (email: string, role: WorkspaceMemberRole) => Promise<void>;
|
||||
}
|
||||
|
||||
export function InviteMember({ onInvite }: InviteMemberProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<WorkspaceMemberRole>(WorkspaceMemberRole.MEMBER);
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!email.trim()) {
|
||||
setError("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.includes("@")) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInviting(true);
|
||||
try {
|
||||
await onInvite(email.trim(), role);
|
||||
setEmail("");
|
||||
setRole(WorkspaceMemberRole.MEMBER);
|
||||
alert("Invitation sent successfully!");
|
||||
} catch (error) {
|
||||
console.error("Failed to invite member:", error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to send invitation. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Invite Member
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="colleague@example.com"
|
||||
disabled={isInviting}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="role"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as WorkspaceMemberRole)}
|
||||
disabled={isInviting}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
|
||||
>
|
||||
<option value={WorkspaceMemberRole.ADMIN}>
|
||||
Admin - Can manage workspace and members
|
||||
</option>
|
||||
<option value={WorkspaceMemberRole.MEMBER}>
|
||||
Member - Can create and edit content
|
||||
</option>
|
||||
<option value={WorkspaceMemberRole.GUEST}>
|
||||
Guest - View-only access
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isInviting}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isInviting ? "Sending Invitation..." : "Send Invitation"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
💡 The invited user will receive an email with instructions to join
|
||||
this workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
apps/web/src/components/workspace/MemberList.tsx
Normal file
145
apps/web/src/components/workspace/MemberList.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import type { User, WorkspaceMember } from "@mosaic/shared";
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
|
||||
export interface WorkspaceMemberWithUser extends WorkspaceMember {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface MemberListProps {
|
||||
members: WorkspaceMemberWithUser[];
|
||||
currentUserId: string;
|
||||
currentUserRole: WorkspaceMemberRole;
|
||||
workspaceOwnerId: string;
|
||||
onRoleChange: (userId: string, newRole: WorkspaceMemberRole) => Promise<void>;
|
||||
onRemove: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const roleColors: Record<WorkspaceMemberRole, string> = {
|
||||
[WorkspaceMemberRole.OWNER]: "bg-purple-100 text-purple-700",
|
||||
[WorkspaceMemberRole.ADMIN]: "bg-blue-100 text-blue-700",
|
||||
[WorkspaceMemberRole.MEMBER]: "bg-green-100 text-green-700",
|
||||
[WorkspaceMemberRole.GUEST]: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
export function MemberList({
|
||||
members,
|
||||
currentUserId,
|
||||
currentUserRole,
|
||||
workspaceOwnerId,
|
||||
onRoleChange,
|
||||
onRemove,
|
||||
}: MemberListProps) {
|
||||
const canManageMembers =
|
||||
currentUserRole === WorkspaceMemberRole.OWNER ||
|
||||
currentUserRole === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole) => {
|
||||
try {
|
||||
await onRoleChange(userId, newRole);
|
||||
} catch (error) {
|
||||
console.error("Failed to change role:", error);
|
||||
alert("Failed to change member role");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (userId: string) => {
|
||||
if (!confirm("Are you sure you want to remove this member?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onRemove(userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove member:", error);
|
||||
alert("Failed to remove member");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Members ({members.length})
|
||||
</h2>
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{members.map((member) => {
|
||||
const isCurrentUser = member.userId === currentUserId;
|
||||
const isOwner = member.userId === workspaceOwnerId;
|
||||
const canModify = canManageMembers && !isOwner && !isCurrentUser;
|
||||
|
||||
return (
|
||||
<li key={member.userId} className="py-4 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{member.user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900">
|
||||
{member.user.name}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-gray-500">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{member.user.email}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Joined {new Date(member.joinedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{canModify ? (
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) =>
|
||||
handleRoleChange(
|
||||
member.userId,
|
||||
e.target.value as WorkspaceMemberRole
|
||||
)
|
||||
}
|
||||
className="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value={WorkspaceMemberRole.ADMIN}>Admin</option>
|
||||
<option value={WorkspaceMemberRole.MEMBER}>Member</option>
|
||||
<option value={WorkspaceMemberRole.GUEST}>Guest</option>
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[member.role]}`}
|
||||
>
|
||||
{member.role}
|
||||
</span>
|
||||
)}
|
||||
{canModify && (
|
||||
<button
|
||||
onClick={() => handleRemove(member.userId)}
|
||||
className="text-red-600 hover:text-red-700 text-sm"
|
||||
aria-label="Remove member"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/workspace/WorkspaceCard.tsx
Normal file
66
apps/web/src/components/workspace/WorkspaceCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Workspace } from "@mosaic/shared";
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
import Link from "next/link";
|
||||
|
||||
interface WorkspaceCardProps {
|
||||
workspace: Workspace;
|
||||
userRole: WorkspaceMemberRole;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
const roleColors: Record<WorkspaceMemberRole, string> = {
|
||||
[WorkspaceMemberRole.OWNER]: "bg-purple-100 text-purple-700",
|
||||
[WorkspaceMemberRole.ADMIN]: "bg-blue-100 text-blue-700",
|
||||
[WorkspaceMemberRole.MEMBER]: "bg-green-100 text-green-700",
|
||||
[WorkspaceMemberRole.GUEST]: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
const roleLabels: Record<WorkspaceMemberRole, string> = {
|
||||
[WorkspaceMemberRole.OWNER]: "Owner",
|
||||
[WorkspaceMemberRole.ADMIN]: "Admin",
|
||||
[WorkspaceMemberRole.MEMBER]: "Member",
|
||||
[WorkspaceMemberRole.GUEST]: "Guest",
|
||||
};
|
||||
|
||||
export function WorkspaceCard({ workspace, userRole, memberCount }: WorkspaceCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/settings/workspaces/${workspace.id}`}
|
||||
className="block bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{workspace.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[userRole]}`}
|
||||
>
|
||||
{roleLabels[userRole]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Created {new Date(workspace.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
183
apps/web/src/components/workspace/WorkspaceSettings.tsx
Normal file
183
apps/web/src/components/workspace/WorkspaceSettings.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Workspace } from "@mosaic/shared";
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
|
||||
interface WorkspaceSettingsProps {
|
||||
workspace: Workspace;
|
||||
userRole: WorkspaceMemberRole;
|
||||
onUpdate: (name: string) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function WorkspaceSettings({
|
||||
workspace,
|
||||
userRole,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: WorkspaceSettingsProps) {
|
||||
const [name, setName] = useState(workspace.name);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const canEdit = userRole === WorkspaceMemberRole.OWNER || userRole === WorkspaceMemberRole.ADMIN;
|
||||
const canDelete = userRole === WorkspaceMemberRole.OWNER;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (name.trim() === "" || name === workspace.name) {
|
||||
setIsEditing(false);
|
||||
setName(workspace.name);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdate(name);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update workspace:", error);
|
||||
alert("Failed to update workspace");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete workspace:", error);
|
||||
alert("Failed to delete workspace");
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">
|
||||
Workspace Settings
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Workspace Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="workspace-name"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Workspace Name
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="workspace-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setName(workspace.name);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-900">{workspace.name}</p>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Workspace ID
|
||||
</label>
|
||||
<code className="block px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-600">
|
||||
{workspace.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Created Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Created
|
||||
</label>
|
||||
<p className="text-gray-600">
|
||||
{new Date(workspace.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Workspace */}
|
||||
{canDelete && (
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-medium text-red-700 mb-2">
|
||||
Danger Zone
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Deleting this workspace will permanently remove all associated
|
||||
data, including tasks, events, and projects. This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
{showDeleteConfirm ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Are you sure you want to delete this workspace?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Yes, Delete Workspace"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Workspace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user