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:
168
apps/web/src/app/(authenticated)/knowledge/new/page.tsx
Normal file
168
apps/web/src/app/(authenticated)/knowledge/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user