mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
Merge pull request #777 from coleam00/refactor/projects-ui
Refactor the UI and add Documents back.
This commit is contained in:
@@ -129,11 +129,12 @@ export function MainLayout({ children, className }: MainLayoutProps) {
|
||||
}, [isBackendError, backendError, showToast]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative min-h-screen bg-white dark:bg-black overflow-hidden", className)}>
|
||||
<div className={cn("relative min-h-screen overflow-hidden", className)}>
|
||||
{/* TEMPORARY: Show backend startup error using old component */}
|
||||
{backendStartupFailed && <BackendStartupError />}
|
||||
|
||||
{/* Fixed full-page background grid that doesn't scroll */}
|
||||
{/* Fixed full-page background - grid pattern on dark background */}
|
||||
<div className="fixed inset-0 bg-white dark:bg-black pointer-events-none -z-10" />
|
||||
<div className="fixed inset-0 neon-grid pointer-events-none z-0" />
|
||||
|
||||
{/* Floating Navigation */}
|
||||
@@ -143,7 +144,7 @@ export function MainLayout({ children, className }: MainLayoutProps) {
|
||||
</div>
|
||||
|
||||
{/* Main Content Area - matches old layout exactly */}
|
||||
<div className="relative flex-1 pl-[100px] z-10">
|
||||
<div className="relative flex-1 pl-[100px]">
|
||||
<div className="container mx-auto px-8 relative">
|
||||
<div className="min-h-screen pt-8 pb-16">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, CheckCircle2, ListTodo } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { isOptimistic } from "@/features/shared/utils/optimistic";
|
||||
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
|
||||
import { SelectableCard } from "../../ui/primitives/selectable-card";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import type { Project } from "../types";
|
||||
import { ProjectCardActions } from "./ProjectCardActions";
|
||||
@@ -33,46 +33,24 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
const optimistic = isOptimistic(project);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
aria-label={`Select project ${project.title}`}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(project);
|
||||
}
|
||||
}}
|
||||
onClick={() => onSelect(project)}
|
||||
<SelectableCard
|
||||
isSelected={isSelected}
|
||||
isPinned={project.pinned}
|
||||
showAuroraGlow={isSelected}
|
||||
onSelect={() => onSelect(project)}
|
||||
blur="xl"
|
||||
transparency="light"
|
||||
size="none"
|
||||
className={cn(
|
||||
"relative rounded-xl backdrop-blur-md w-72 min-h-[180px] cursor-pointer overflow-visible group flex flex-col",
|
||||
"transition-all duration-300",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900",
|
||||
"w-72 min-h-[180px] flex flex-col shrink-0",
|
||||
project.pinned
|
||||
? "bg-gradient-to-b from-purple-100/80 via-purple-50/30 to-purple-100/50 dark:from-purple-900/30 dark:via-purple-900/20 dark:to-purple-900/10"
|
||||
: isSelected
|
||||
? "bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20"
|
||||
: "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||
"border",
|
||||
project.pinned
|
||||
? "border-purple-500/80 dark:border-purple-500/80 shadow-[0_0_15px_rgba(168,85,247,0.3)]"
|
||||
: isSelected
|
||||
? "border-purple-400/60 dark:border-purple-500/60"
|
||||
: "border-gray-200 dark:border-zinc-800/50",
|
||||
isSelected
|
||||
? "shadow-[0_0_15px_rgba(168,85,247,0.4),0_0_10px_rgba(147,51,234,0.3)] dark:shadow-[0_0_20px_rgba(168,85,247,0.5),0_0_15px_rgba(147,51,234,0.4)]"
|
||||
: "shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
"hover:shadow-[0_15px_40px_-15px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_15px_40px_-15px_rgba(0,0,0,0.9)]",
|
||||
isSelected ? "scale-[1.02]" : "hover:scale-[1.01]", // Use scale instead of translate to avoid clipping
|
||||
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
|
||||
)}
|
||||
>
|
||||
{/* Subtle aurora glow effect for selected card */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-30 dark:opacity-40 pointer-events-none">
|
||||
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(168,85,247,0.8)_0%,rgba(147,51,234,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area with padding */}
|
||||
<div className="flex-1 p-4 pb-2">
|
||||
@@ -94,7 +72,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Task count pills */}
|
||||
<div className="flex items-stretch gap-2 w-full">
|
||||
<div className="flex flex-col sm:flex-row items-stretch gap-2 w-full">
|
||||
{/* Todo pill */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
@@ -253,7 +231,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
<div className="flex items-center justify-between px-3 py-2 mt-auto border-t border-gray-200/30 dark:border-gray-700/20">
|
||||
{/* Pinned indicator badge */}
|
||||
{project.pinned ? (
|
||||
<div className="px-2 py-0.5 bg-purple-500 text-white text-[10px] font-bold rounded-full shadow-lg shadow-purple-500/30">
|
||||
<div className="px-2 py-0.5 bg-purple-500 dark:bg-purple-600 text-white text-[10px] font-bold rounded-full shadow-lg shadow-purple-500/30">
|
||||
DEFAULT
|
||||
</div>
|
||||
) : (
|
||||
@@ -275,6 +253,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</SelectableCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus } from "lucide-react";
|
||||
import { LayoutGrid, List, Plus, Search, X } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { Button } from "../../ui/primitives/button";
|
||||
import { Input } from "../../ui/primitives/input";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
|
||||
interface ProjectHeaderProps {
|
||||
onNewProject: () => void;
|
||||
layoutMode?: "horizontal" | "sidebar";
|
||||
onLayoutModeChange?: (mode: "horizontal" | "sidebar") => void;
|
||||
rightContent?: ReactNode;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
}
|
||||
|
||||
const titleVariants = {
|
||||
@@ -25,7 +33,14 @@ const itemVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectHeader: React.FC<ProjectHeaderProps> = ({ onNewProject }) => {
|
||||
export const ProjectHeader: React.FC<ProjectHeaderProps> = ({
|
||||
onNewProject,
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
rightContent,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center justify-between mb-8"
|
||||
@@ -44,10 +59,62 @@ export const ProjectHeader: React.FC<ProjectHeaderProps> = ({ onNewProject }) =>
|
||||
/>
|
||||
Projects
|
||||
</motion.h1>
|
||||
<Button onClick={onNewProject} variant="cyan" className="shadow-lg shadow-cyan-500/20">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search input */}
|
||||
{searchQuery !== undefined && onSearchChange && (
|
||||
<div className="relative w-64">
|
||||
<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 projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 pr-8"
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Layout toggle - show if mode and change handler provided */}
|
||||
{layoutMode && onLayoutModeChange && (
|
||||
<div className="flex gap-1 p-1 bg-black/30 dark:bg-black/50 rounded-lg border border-white/10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onLayoutModeChange("horizontal")}
|
||||
className={cn("px-3", layoutMode === "horizontal" && "bg-purple-500/20 text-purple-400")}
|
||||
aria-label="Switch to horizontal layout"
|
||||
aria-pressed={layoutMode === "horizontal"}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onLayoutModeChange("sidebar")}
|
||||
className={cn("px-3", layoutMode === "sidebar" && "bg-purple-500/20 text-purple-400")}
|
||||
aria-label="Switch to sidebar layout"
|
||||
aria-pressed={layoutMode === "sidebar"}
|
||||
>
|
||||
<List className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{rightContent}
|
||||
<Button onClick={onNewProject} variant="cyan" className="shadow-lg shadow-cyan-500/20">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ProjectList: React.FC<ProjectListProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial="hidden" animate="visible" className="relative mb-10" variants={itemVariants}>
|
||||
<motion.div initial="hidden" animate="visible" className="relative mb-10 w-full" variants={itemVariants}>
|
||||
<div className="overflow-x-auto overflow-y-visible pb-4 pt-2 pr-6 md:pr-8 scrollbar-thin">
|
||||
<ul className="flex gap-4 min-w-max pl-6 md:pl-8" aria-label="Projects">
|
||||
{sortedProjects.map((project) => (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { FileText, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "../../ui/primitives";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
||||
import { Button, Input } from "../../ui/primitives";
|
||||
import { AddDocumentModal } from "./components/AddDocumentModal";
|
||||
import { DocumentCard } from "./components/DocumentCard";
|
||||
import { DocumentViewer } from "./components/DocumentViewer";
|
||||
import { useProjectDocuments } from "./hooks";
|
||||
import type { ProjectDocument } from "./types";
|
||||
import { useCreateDocument, useDeleteDocument, useProjectDocuments, useUpdateDocument } from "./hooks";
|
||||
import type { DocumentContent, ProjectDocument } from "./types";
|
||||
|
||||
interface DocsTabProps {
|
||||
project?: {
|
||||
@@ -25,10 +26,75 @@ export const DocsTab = ({ project }: DocsTabProps) => {
|
||||
|
||||
// Fetch documents from project's docs field
|
||||
const { data: documents = [], isLoading } = useProjectDocuments(projectId);
|
||||
const updateDocumentMutation = useUpdateDocument(projectId);
|
||||
const createDocumentMutation = useCreateDocument(projectId);
|
||||
const deleteDocumentMutation = useDeleteDocument(projectId);
|
||||
|
||||
// Document state
|
||||
const [selectedDocument, setSelectedDocument] = useState<ProjectDocument | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<ProjectDocument | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Handle document save
|
||||
const handleSaveDocument = async (documentId: string, content: DocumentContent) => {
|
||||
try {
|
||||
await updateDocumentMutation.mutateAsync({
|
||||
documentId,
|
||||
updates: { content },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save document:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle add document
|
||||
const handleAddDocument = async (title: string, document_type: string) => {
|
||||
await createDocumentMutation.mutateAsync({
|
||||
title,
|
||||
document_type,
|
||||
content: { markdown: "# " + title + "\n\nStart writing your document here..." },
|
||||
// NOTE: Archon does not have user authentication - this is a single-user local app.
|
||||
// "User" is a constant representing the sole user of this Archon instance.
|
||||
author: "User",
|
||||
});
|
||||
};
|
||||
|
||||
// Handle delete document
|
||||
const handleDeleteDocument = (doc: ProjectDocument) => {
|
||||
setDocumentToDelete(doc);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!documentToDelete) return;
|
||||
|
||||
await deleteDocumentMutation.mutateAsync(documentToDelete.id);
|
||||
|
||||
// Clear selection if deleted document was selected
|
||||
if (selectedDocument?.id === documentToDelete.id) {
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setDocumentToDelete(null);
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setShowDeleteModal(false);
|
||||
setDocumentToDelete(null);
|
||||
};
|
||||
|
||||
// Reset state when project changes
|
||||
useEffect(() => {
|
||||
setSelectedDocument(null);
|
||||
setSearchQuery("");
|
||||
setShowAddModal(false);
|
||||
setShowDeleteModal(false);
|
||||
setDocumentToDelete(null);
|
||||
}, [projectId]);
|
||||
|
||||
// Auto-select first document when documents load
|
||||
useEffect(() => {
|
||||
@@ -59,111 +125,97 @@ export const DocsTab = ({ project }: DocsTabProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-200px)]">
|
||||
{/* Migration Warning Banner */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-yellow-600 dark:text-yellow-400">
|
||||
<svg className="w-5 h-5 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-label="Warning">
|
||||
<title>Warning icon</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-yellow-800 dark:text-yellow-300">
|
||||
Project Documents Under Migration
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
|
||||
Editing and uploading project documents is currently disabled while we migrate to a new storage system.
|
||||
<strong className="font-semibold">
|
||||
{" "}
|
||||
Please backup your existing project documents elsewhere as they will be lost when the migration is
|
||||
complete.
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-1">
|
||||
Note: This only affects project-specific documents. Your knowledge base documents are safe and unaffected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[600px] gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1">
|
||||
{/* Left Sidebar - Document List */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-80 flex flex-col",
|
||||
"border-r border-gray-200 dark:border-gray-700",
|
||||
"bg-gray-50 dark:bg-gray-900",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 text-gray-800 dark:text-white">
|
||||
<FileText className="w-5 h-5" />
|
||||
Documents (Read-Only)
|
||||
</h2>
|
||||
|
||||
{/* 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 documents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info message */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
Viewing {documents.length} document{documents.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{/* Left Sidebar - Document List */}
|
||||
<div className="w-64 flex flex-col space-y-4 overflow-visible">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Documents</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||
aria-label="Add new document"
|
||||
>
|
||||
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
<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..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
aria-label="Search documents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{documents.length} document{documents.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="h-full overflow-y-auto space-y-2 p-2 -mx-2">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">{searchQuery ? "No documents found" : "No documents in this project"}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocuments.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isActive={selectedDocument?.id === doc.id}
|
||||
onSelect={setSelectedDocument}
|
||||
onDelete={() => {}} // No delete in read-only mode
|
||||
/>
|
||||
))
|
||||
<div className="space-y-2">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isActive={selectedDocument?.id === doc.id}
|
||||
onSelect={setSelectedDocument}
|
||||
onDelete={handleDeleteDocument}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Document Viewer */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-900">
|
||||
{selectedDocument ? (
|
||||
<DocumentViewer document={selectedDocument} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="w-16 h-16 text-gray-300 dark:text-gray-700 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{documents.length > 0 ? "Select a document to view" : "No documents available"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Document Viewer */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedDocument ? (
|
||||
<DocumentViewer document={selectedDocument} onSave={handleSaveDocument} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="w-16 h-16 text-gray-300 dark:text-gray-700 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{documents.length > 0 ? "Select a document to view" : "No documents available"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Document Modal */}
|
||||
<AddDocumentModal open={showAddModal} onOpenChange={setShowAddModal} onAdd={handleAddDocument} />
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmModal
|
||||
open={showDeleteModal}
|
||||
onOpenChange={(open) => {
|
||||
setShowDeleteModal(open);
|
||||
if (!open) setDocumentToDelete(null);
|
||||
}}
|
||||
itemName={documentToDelete?.title ?? ""}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={cancelDelete}
|
||||
type="document"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../ui/primitives";
|
||||
|
||||
interface AddDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (title: string, type: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModalProps) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState("spec");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset form state when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTitle("");
|
||||
setType("spec");
|
||||
setError(null);
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
setIsAdding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onAdd(title, type);
|
||||
setTitle("");
|
||||
setType("spec");
|
||||
setError(null);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
typeof err === "string"
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create document"
|
||||
);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Document</DialogTitle>
|
||||
<DialogDescription>Create a new document for this project</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 my-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="document-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Document Title
|
||||
</label>
|
||||
<Input
|
||||
id="document-title"
|
||||
type="text"
|
||||
placeholder="Enter document title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isAdding}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="document-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Document Type
|
||||
</label>
|
||||
<Select value={type} onValueChange={setType} disabled={isAdding}>
|
||||
<SelectTrigger className="w-full" color="cyan">
|
||||
<SelectValue placeholder="Select a document type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent color="cyan">
|
||||
<SelectItem value="spec" color="cyan">Specification</SelectItem>
|
||||
<SelectItem value="api" color="cyan">API Documentation</SelectItem>
|
||||
<SelectItem value="guide" color="cyan">Guide</SelectItem>
|
||||
<SelectItem value="note" color="cyan">Note</SelectItem>
|
||||
<SelectItem value="design" color="cyan">Design</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isAdding}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="default" disabled={isAdding || !title.trim()}>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
"Add Document"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
FileText,
|
||||
Info,
|
||||
Rocket,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { copyToClipboard } from "../../../shared/utils/clipboard";
|
||||
import { Button } from "../../../ui/primitives";
|
||||
import { Button, Card } from "../../../ui/primitives";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import type { DocumentCardProps, DocumentType } from "../types";
|
||||
|
||||
const getDocumentIcon = (type?: DocumentType) => {
|
||||
@@ -43,23 +44,23 @@ const getDocumentIcon = (type?: DocumentType) => {
|
||||
const getTypeColor = (type?: DocumentType) => {
|
||||
switch (type) {
|
||||
case "prp":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30";
|
||||
return { badge: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30", glow: "blue" };
|
||||
case "technical":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30";
|
||||
return { badge: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30", glow: "green" };
|
||||
case "business":
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30";
|
||||
return { badge: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30", glow: "purple" };
|
||||
case "meeting_notes":
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30";
|
||||
return { badge: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30", glow: "orange" };
|
||||
case "spec":
|
||||
return "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/30";
|
||||
return { badge: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/30", glow: "cyan" };
|
||||
case "design":
|
||||
return "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30";
|
||||
return { badge: "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30", glow: "pink" };
|
||||
case "api":
|
||||
return "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-indigo-500/30";
|
||||
return { badge: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30", glow: "green" };
|
||||
case "guide":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
return { badge: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30", glow: "orange" };
|
||||
default:
|
||||
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30";
|
||||
return { badge: "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30", glow: "cyan" };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,6 +68,8 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const typeColors = getTypeColor(document.document_type as DocumentType);
|
||||
|
||||
const handleCopyId = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -87,87 +90,95 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
|
||||
[document, onDelete],
|
||||
);
|
||||
|
||||
const handleCardClick = () => {
|
||||
onSelect(document);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(document);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Complex card with nested interactive elements - semantic button would break layout
|
||||
<div
|
||||
<Card
|
||||
blur="none"
|
||||
transparency="light"
|
||||
glowColor={isActive ? (typeColors.glow as any) : "none"}
|
||||
glowType="inner"
|
||||
glowSize="md"
|
||||
size="sm"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(document);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
relative flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer
|
||||
transition-all duration-200 group
|
||||
${
|
||||
isActive
|
||||
? "bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 shadow-lg scale-105"
|
||||
: "bg-white/50 dark:bg-black/30 border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
onClick={() => onSelect(document)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowDelete(true)}
|
||||
onMouseLeave={() => setShowDelete(false)}
|
||||
aria-label={`${isActive ? "Selected: " : ""}${document.title}`}
|
||||
className={cn("relative w-full cursor-pointer transition-all duration-300 group", isActive && "scale-[1.02]")}
|
||||
>
|
||||
{/* Document Type Badge */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mb-2 border ${getTypeColor(
|
||||
document.document_type as DocumentType,
|
||||
)}`}
|
||||
>
|
||||
{getDocumentIcon(document.document_type as DocumentType)}
|
||||
<span>{document.document_type || "document"}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm line-clamp-2 mb-1">{document.title}</h4>
|
||||
|
||||
{/* Metadata */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{new Date(document.updated_at || document.created_at || Date.now()).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
{/* ID Display Section - Always visible for active, hover for others */}
|
||||
<div
|
||||
className={`flex items-center justify-between mt-2 ${
|
||||
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
} transition-opacity duration-200`}
|
||||
>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 truncate max-w-[120px]" title={document.id}>
|
||||
{document.id.slice(0, 8)}...
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyId}
|
||||
className="p-1 h-auto min-h-0"
|
||||
title="Copy Document ID to clipboard"
|
||||
aria-label="Copy Document ID to clipboard"
|
||||
>
|
||||
{isCopied ? (
|
||||
<span className="text-green-500 text-xs">✓</span>
|
||||
) : (
|
||||
<Clipboard className="w-3 h-3" aria-hidden="true" />
|
||||
<div>
|
||||
{/* Document Type Badge */}
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mb-2 border",
|
||||
typeColors.badge,
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
{showDelete && !isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="absolute top-2 right-2 p-1 h-auto min-h-0 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
aria-label={`Delete ${document.title}`}
|
||||
title="Delete document"
|
||||
>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{getDocumentIcon(document.document_type as DocumentType)}
|
||||
<span>{document.document_type || "document"}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm line-clamp-2 mb-1">{document.title}</h4>
|
||||
|
||||
{/* Metadata */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{new Date(document.updated_at || document.created_at || Date.now()).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
{/* ID Display Section - Always visible for active, hover for others */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between mt-2 transition-opacity duration-200",
|
||||
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 truncate max-w-[120px]" title={document.id}>
|
||||
{document.id.slice(0, 8)}...
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyId}
|
||||
className="p-1 h-auto min-h-0"
|
||||
title="Copy Document ID to clipboard"
|
||||
aria-label="Copy Document ID to clipboard"
|
||||
>
|
||||
{isCopied ? (
|
||||
<span className="text-green-500 text-xs">✓</span>
|
||||
) : (
|
||||
<Clipboard className="w-3 h-3" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Delete Button - show on hover OR when selected */}
|
||||
{(showDelete || isActive) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="absolute top-2 right-2 p-1 h-auto min-h-0 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
aria-label={`Delete ${document.title}`}
|
||||
title="Delete document"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +1,142 @@
|
||||
import { FileText } from "lucide-react";
|
||||
import { Edit3, Eye, FileText, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button, Card } from "../../../ui/primitives";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import { SimpleTooltip } from "../../../ui/primitives/tooltip";
|
||||
import type { ProjectDocument } from "../types";
|
||||
|
||||
interface DocumentViewerProps {
|
||||
document: ProjectDocument;
|
||||
onSave?: (documentId: string, content: any) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple read-only document viewer
|
||||
* Displays document content in a reliable way without complex editing
|
||||
*/
|
||||
export const DocumentViewer = ({ document }: DocumentViewerProps) => {
|
||||
export const DocumentViewer = ({ document, onSave }: DocumentViewerProps) => {
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editedContent, setEditedContent] = useState("");
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Get markdown content as string
|
||||
const getMarkdownContent = (): string => {
|
||||
if (!document.content) return "";
|
||||
|
||||
if (typeof document.content === "string") return document.content;
|
||||
if ("markdown" in document.content && typeof document.content.markdown === "string") {
|
||||
return document.content.markdown;
|
||||
}
|
||||
if ("text" in document.content && typeof document.content.text === "string") {
|
||||
return document.content.text;
|
||||
}
|
||||
if ("sections" in document.content && Array.isArray(document.content.sections)) {
|
||||
return document.content.sections
|
||||
.map((s: any) => `${s.heading ? `# ${s.heading}\n\n` : ""}${s.content || ""}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
return JSON.stringify(document.content, null, 2);
|
||||
};
|
||||
|
||||
// Initialize edited content when switching to edit mode
|
||||
const handleToggleEdit = () => {
|
||||
if (!isEditMode) {
|
||||
setEditedContent(getMarkdownContent());
|
||||
}
|
||||
setIsEditMode(!isEditMode);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setEditedContent(value);
|
||||
setHasChanges(value !== getMarkdownContent());
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave || !hasChanges) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(document.id, { markdown: editedContent });
|
||||
setHasChanges(false);
|
||||
setIsEditMode(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save document:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract content for display
|
||||
const renderContent = () => {
|
||||
if (!document.content) {
|
||||
return <p className="text-gray-500 italic">No content available</p>;
|
||||
return <p className="text-gray-500 dark:text-gray-400 italic">No content available</p>;
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof document.content === "string") {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">{document.content}</pre>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{document.content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle markdown field
|
||||
if ("markdown" in document.content && typeof document.content.markdown === "string") {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 mt-6" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3 mt-5" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 mt-4" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3 leading-relaxed" {...props} />
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-inside text-sm text-gray-700 dark:text-gray-300 mb-3 space-y-1"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol
|
||||
className="list-decimal list-inside text-sm text-gray-700 dark:text-gray-300 mb-3 space-y-1"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node, ...props }) => <li className="ml-4" {...props} />,
|
||||
code: ({ node, ...props }) => (
|
||||
<code
|
||||
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono text-cyan-600 dark:text-cyan-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ node, ...props }) => (
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded-lg overflow-x-auto mb-3" {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => <a className="text-cyan-600 dark:text-cyan-400 hover:underline" {...props} />,
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-gray-300 dark:border-gray-700 pl-4 italic text-gray-600 dark:text-gray-400 my-3"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{document.content.markdown}
|
||||
</pre>
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,78 +144,136 @@ export const DocumentViewer = ({ document }: DocumentViewerProps) => {
|
||||
// Handle text field
|
||||
if ("text" in document.content && typeof document.content.text === "string") {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">
|
||||
{document.content.text}
|
||||
</pre>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{document.content.text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle structured content (JSON)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(document.content).map(([key, value]) => (
|
||||
<div key={key} className="border-l-2 border-gray-300 dark:border-gray-700 pl-4">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{key.replace(/_/g, " ").charAt(0).toUpperCase() + key.replace(/_/g, " ").slice(1)}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{typeof value === "string" ? (
|
||||
<p>{value}</p>
|
||||
) : Array.isArray(value) ? (
|
||||
<ul className="list-disc pl-5">
|
||||
{value.map((item, i) => (
|
||||
<li key={`${key}-${typeof item === "object" ? JSON.stringify(item) : String(item)}-${i}`}>
|
||||
{typeof item === "object" ? JSON.stringify(item, null, 2) : String(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
// Handle sections array (structured content)
|
||||
if ("sections" in document.content && Array.isArray(document.content.sections)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{document.content.sections.map((section: any, index: number) => (
|
||||
<div key={index} className="space-y-2">
|
||||
{section.heading && (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white border-l-4 border-cyan-500 pl-3">
|
||||
{section.heading}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap pl-3">
|
||||
{section.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: render JSON as formatted text
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-xs overflow-x-auto text-gray-700 dark:text-gray-300">
|
||||
{JSON.stringify(document.content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="space-y-4">
|
||||
{/* Metadata Card - Blue glass with bottom edge-lit */}
|
||||
<Card
|
||||
blur="md"
|
||||
transparency="light"
|
||||
edgePosition="bottom"
|
||||
edgeColor="blue"
|
||||
size="md"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-gray-500" />
|
||||
<FileText className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">{document.title}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Type: {document.document_type || "document"} • Last updated:{" "}
|
||||
{new Date(document.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{document.title}</h2>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Type:{" "}
|
||||
<span className="px-2 py-1 text-xs bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded">
|
||||
{document.document_type || "document"}
|
||||
</span>
|
||||
</span>
|
||||
{document.updated_at && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last updated: {new Date(document.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{document.tags && document.tags.length > 0 && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{document.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded",
|
||||
"bg-gray-100 dark:bg-gray-800",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
"border border-gray-300 dark:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6 bg-white dark:bg-gray-900">{renderContent()}</div>
|
||||
{/* Content Card - Medium blur glass */}
|
||||
<Card blur="md" transparency="light" size="lg" className="overflow-visible">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Content</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Save button - only show in edit mode with changes */}
|
||||
{isEditMode && hasChanges && (
|
||||
<SimpleTooltip content={isSaving ? "Saving..." : "Save changes"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="text-green-600 dark:text-green-400 hover:bg-green-500/10"
|
||||
aria-label="Save document"
|
||||
>
|
||||
<Save className={cn("w-4 h-4", isSaving && "animate-pulse")} aria-hidden="true" />
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{/* View/Edit toggle */}
|
||||
<SimpleTooltip content={isEditMode ? "Preview mode" : "Edit mode"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-500/10"
|
||||
aria-label={isEditMode ? "Switch to preview mode" : "Switch to edit mode"}
|
||||
aria-pressed={isEditMode}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<Eye className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Edit3 className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditMode ? (
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full min-h-[400px] p-4 rounded-lg",
|
||||
"bg-white/50 dark:bg-black/30",
|
||||
"border border-gray-300 dark:border-gray-700",
|
||||
"text-gray-900 dark:text-white font-mono text-sm",
|
||||
"focus:outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/20",
|
||||
"resize-y",
|
||||
)}
|
||||
placeholder="Enter markdown content..."
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-700 dark:text-gray-300">{renderContent()}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Document Hooks
|
||||
*
|
||||
* Read-only hooks for document display
|
||||
* Hooks for document display and editing
|
||||
*/
|
||||
|
||||
export { useProjectDocuments } from "./useDocumentQueries";
|
||||
export { useCreateDocument, useDeleteDocument, useProjectDocuments, useUpdateDocument } from "./useDocumentQueries";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { callAPIWithETag } from "../../../shared/api/apiClient";
|
||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/config/queryPatterns";
|
||||
import { projectService } from "../../services";
|
||||
import { useToast } from "../../../shared/hooks/useToast";
|
||||
import { documentService } from "../services/documentService";
|
||||
import type { ProjectDocument } from "../types";
|
||||
|
||||
// Query keys factory for documents
|
||||
@@ -14,18 +16,122 @@ export const documentKeys = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get documents from project's docs JSONB field
|
||||
* Read-only - no mutations
|
||||
* Get documents for a project from Archon documents API
|
||||
*/
|
||||
export function useProjectDocuments(projectId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: projectId ? documentKeys.byProject(projectId) : DISABLED_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
if (!projectId) return [];
|
||||
const project = await projectService.getProject(projectId);
|
||||
return (project.docs || []) as ProjectDocument[];
|
||||
return await documentService.getDocumentsByProject(projectId);
|
||||
},
|
||||
enabled: !!projectId,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single document by ID
|
||||
*/
|
||||
export function useProjectDocument(projectId: string | undefined, documentId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: projectId && documentId ? documentKeys.detail(projectId, documentId) : DISABLED_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
if (!projectId || !documentId) return null;
|
||||
return await documentService.getDocument(projectId, documentId);
|
||||
},
|
||||
enabled: !!(projectId && documentId),
|
||||
staleTime: STALE_TIMES.normal,
|
||||
});
|
||||
}
|
||||
|
||||
// Type for document updates
|
||||
export interface DocumentUpdateData {
|
||||
documentId: string;
|
||||
updates: { title?: string; content?: unknown; tags?: string[]; author?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project document
|
||||
*/
|
||||
export function useUpdateDocument(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ documentId, updates }: DocumentUpdateData) => {
|
||||
return await documentService.updateDocument(projectId, documentId, updates);
|
||||
},
|
||||
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate documents list to refetch with new content
|
||||
queryClient.invalidateQueries({ queryKey: documentKeys.byProject(projectId) });
|
||||
// Invalidate the specific document detail to update open viewers
|
||||
queryClient.invalidateQueries({ queryKey: documentKeys.detail(projectId, variables.documentId) });
|
||||
showToast("Document updated successfully", "success");
|
||||
},
|
||||
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to update document: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project document
|
||||
*/
|
||||
export function useCreateDocument(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (document: {
|
||||
title: string;
|
||||
document_type: string;
|
||||
content?: any;
|
||||
tags?: string[];
|
||||
author?: string;
|
||||
}) => {
|
||||
const response = await callAPIWithETag<{ success: boolean; message: string; document: ProjectDocument }>(
|
||||
`/api/projects/${projectId}/docs`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(document),
|
||||
},
|
||||
);
|
||||
return response.document;
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: documentKeys.byProject(projectId) });
|
||||
showToast("Document created successfully", "success");
|
||||
},
|
||||
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to create document: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project document
|
||||
*/
|
||||
export function useDeleteDocument(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (documentId: string) => {
|
||||
return await documentService.deleteDocument(projectId, documentId);
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: documentKeys.byProject(projectId) });
|
||||
showToast("Document deleted successfully", "success");
|
||||
},
|
||||
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to delete document: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Document Service
|
||||
* Handles API calls for project documents via Archon MCP
|
||||
*/
|
||||
|
||||
import { callAPIWithETag } from "../../../shared/api/apiClient";
|
||||
import type { ProjectDocument } from "../types";
|
||||
|
||||
interface DocumentsResponse {
|
||||
success: boolean;
|
||||
documents: ProjectDocument[];
|
||||
count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const documentService = {
|
||||
/**
|
||||
* Get all documents for a project
|
||||
*/
|
||||
async getDocumentsByProject(projectId: string): Promise<ProjectDocument[]> {
|
||||
const response = await callAPIWithETag<DocumentsResponse>(`/api/projects/${projectId}/docs?include_content=true`);
|
||||
return response.documents || [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single document by ID
|
||||
*/
|
||||
async getDocument(projectId: string, documentId: string): Promise<ProjectDocument> {
|
||||
const response = await callAPIWithETag<{ success: boolean; document: ProjectDocument }>(
|
||||
`/api/projects/${projectId}/docs/${documentId}`,
|
||||
);
|
||||
if (!response.document) {
|
||||
throw new Error(`Document not found: ${documentId} in project ${projectId}`);
|
||||
}
|
||||
return response.document;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a document
|
||||
*/
|
||||
async updateDocument(
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
updates: { content?: unknown; title?: string; tags?: string[] },
|
||||
): Promise<ProjectDocument> {
|
||||
const response = await callAPIWithETag<{ success: boolean; document: ProjectDocument }>(
|
||||
`/api/projects/${projectId}/docs/${documentId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updates),
|
||||
},
|
||||
);
|
||||
if (!response.document) {
|
||||
throw new Error(`Failed to update document: ${documentId} in project ${projectId}`);
|
||||
}
|
||||
return response.document;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
async deleteDocument(projectId: string, documentId: string): Promise<void> {
|
||||
await callAPIWithETag<{ success: boolean; message: string }>(
|
||||
`/api/projects/${projectId}/docs/${documentId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useState } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
||||
import { Button } from "../../ui/primitives";
|
||||
import { Button, Card } from "../../ui/primitives";
|
||||
import { cn, glassmorphism } from "../../ui/primitives/styles";
|
||||
import { TaskEditModal } from "./components/TaskEditModal";
|
||||
import { useDeleteTask, useProjectTasks, useUpdateTask } from "./hooks";
|
||||
@@ -260,18 +260,17 @@ const ViewControls = ({ viewMode, onViewChange, onAddTask }: ViewControlsProps)
|
||||
</Button>
|
||||
|
||||
{/* View Toggle Controls with Glassmorphism */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center overflow-hidden pointer-events-auto",
|
||||
glassmorphism.background.subtle,
|
||||
glassmorphism.border.default,
|
||||
glassmorphism.shadow.elevated,
|
||||
"rounded-lg",
|
||||
)}
|
||||
<Card
|
||||
blur="lg"
|
||||
transparency="medium"
|
||||
size="none"
|
||||
className="flex items-center overflow-hidden pointer-events-auto rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewChange("table")}
|
||||
aria-label="Switch to table view"
|
||||
aria-pressed={viewMode === "table"}
|
||||
className={cn(
|
||||
"px-5 py-2.5 flex items-center gap-2 relative transition-all duration-300",
|
||||
viewMode === "table"
|
||||
@@ -279,7 +278,7 @@ const ViewControls = ({ viewMode, onViewChange, onAddTask }: ViewControlsProps)
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300",
|
||||
)}
|
||||
>
|
||||
<Table className="w-4 h-4" />
|
||||
<Table className="w-4 h-4" aria-hidden="true" />
|
||||
<span>Table</span>
|
||||
{viewMode === "table" && (
|
||||
<span
|
||||
@@ -296,6 +295,8 @@ const ViewControls = ({ viewMode, onViewChange, onAddTask }: ViewControlsProps)
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewChange("board")}
|
||||
aria-label="Switch to board view"
|
||||
aria-pressed={viewMode === "board"}
|
||||
className={cn(
|
||||
"px-5 py-2.5 flex items-center gap-2 relative transition-all duration-300",
|
||||
viewMode === "board"
|
||||
@@ -303,7 +304,7 @@ const ViewControls = ({ viewMode, onViewChange, onAddTask }: ViewControlsProps)
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
<LayoutGrid className="w-4 h-4" aria-hidden="true" />
|
||||
<span>Board</span>
|
||||
{viewMode === "board" && (
|
||||
<span
|
||||
@@ -316,7 +317,7 @@ const ViewControls = ({ viewMode, onViewChange, onAddTask }: ViewControlsProps)
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Activity, CheckCircle2, Eye, ListTodo } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
@@ -32,71 +33,96 @@ export const KanbanColumn = ({
|
||||
}: KanbanColumnProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
const [, drop] = useDrop({
|
||||
accept: ItemTypes.TASK,
|
||||
drop: (item: { id: string; status: Task["status"] }) => {
|
||||
if (item.status !== status) {
|
||||
onTaskMove(item.id, status);
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
drop(ref);
|
||||
|
||||
// Get icon and label based on status
|
||||
const getStatusInfo = () => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return {
|
||||
icon: <ListTodo className="w-3 h-3" />,
|
||||
label: "Todo",
|
||||
color: "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30",
|
||||
};
|
||||
case "doing":
|
||||
return {
|
||||
icon: <Activity className="w-3 h-3" />,
|
||||
label: "Doing",
|
||||
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30",
|
||||
};
|
||||
case "review":
|
||||
return {
|
||||
icon: <Eye className="w-3 h-3" />,
|
||||
label: "Review",
|
||||
color: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30",
|
||||
};
|
||||
case "done":
|
||||
return {
|
||||
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||
label: "Done",
|
||||
color: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <ListTodo className="w-3 h-3" />,
|
||||
label: "Todo",
|
||||
color: "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col h-full",
|
||||
"bg-gradient-to-b from-white/20 to-transparent dark:from-black/30 dark:to-transparent",
|
||||
"backdrop-blur-sm",
|
||||
"transition-all duration-200",
|
||||
isOver && "bg-gradient-to-b from-cyan-500/5 to-purple-500/5 dark:from-cyan-400/10 dark:to-purple-400/10",
|
||||
isOver && "border-t-2 border-t-cyan-400/50 dark:border-t-cyan-400/70",
|
||||
isOver &&
|
||||
"shadow-[inset_0_2px_20px_rgba(34,211,238,0.15)] dark:shadow-[inset_0_2px_30px_rgba(34,211,238,0.25)]",
|
||||
isOver && "backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Column Header with Glassmorphism */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-3 sticky top-0 z-10",
|
||||
"bg-gradient-to-b from-white/80 to-white/60 dark:from-black/80 dark:to-black/60",
|
||||
"backdrop-blur-md",
|
||||
"border-b border-gray-200/50 dark:border-gray-700/50",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
<h3 className={cn("font-mono text-sm font-medium", getColumnColor(status))}>{title}</h3>
|
||||
{/* Column header glow effect */}
|
||||
<div ref={ref} className="flex flex-col h-full">
|
||||
{/* Column Header - pill badge only */}
|
||||
<div className="text-center py-3 relative">
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border backdrop-blur-md",
|
||||
statusInfo.color
|
||||
)}
|
||||
>
|
||||
{statusInfo.icon}
|
||||
<span className="font-medium">{statusInfo.label}</span>
|
||||
<span className="font-bold">{tasks.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Colored underline */}
|
||||
<div
|
||||
className={cn("absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px]", getColumnGlow(status))}
|
||||
className={cn(
|
||||
"absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px]",
|
||||
getColumnGlow(status),
|
||||
"shadow-md",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tasks Container */}
|
||||
<div className="px-2 flex-1 overflow-y-auto space-y-2 py-3 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
|
||||
{tasks.length === 0 ? (
|
||||
<div className={cn("text-center py-8 text-gray-400 dark:text-gray-600 text-sm", "opacity-60")}>No tasks</div>
|
||||
) : (
|
||||
tasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
projectId={projectId}
|
||||
onTaskReorder={onTaskReorder}
|
||||
onEdit={onTaskEdit}
|
||||
onDelete={onTaskDelete}
|
||||
hoveredTaskId={hoveredTaskId}
|
||||
onTaskHover={onTaskHover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{tasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
projectId={projectId}
|
||||
onTaskReorder={onTaskReorder}
|
||||
onEdit={onTaskEdit}
|
||||
onDelete={onTaskDelete}
|
||||
hoveredTaskId={hoveredTaskId}
|
||||
onTaskHover={onTaskHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,9 @@ import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { isOptimistic } from "@/features/shared/utils/optimistic";
|
||||
import { Card } from "../../../ui/primitives";
|
||||
import { OptimisticIndicator } from "../../../ui/primitives/OptimisticIndicator";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import { useTaskActions } from "../hooks";
|
||||
import type { Assignee, Task, TaskPriority } from "../types";
|
||||
import { getOrderColor, getOrderGlow, ItemTypes } from "../utils/task-styles";
|
||||
@@ -120,48 +122,40 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Glassmorphism styling constants
|
||||
const cardBaseStyles =
|
||||
"bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-gray-700 rounded-lg backdrop-blur-md";
|
||||
const transitionStyles = "transition-all duration-200 ease-in-out";
|
||||
|
||||
// Subtle highlight effect for related tasks
|
||||
const highlightGlow = isHighlighted ? "border-cyan-400/50 shadow-[0_0_8px_rgba(34,211,238,0.2)]" : "";
|
||||
|
||||
// Selection styling with glassmorphism
|
||||
const selectionGlow = isSelected
|
||||
? "border-blue-500 shadow-[0_0_12px_rgba(59,130,246,0.4)] bg-blue-50/30 dark:bg-blue-900/20"
|
||||
: "";
|
||||
|
||||
// Beautiful hover effect with glowing borders
|
||||
const hoverEffectClasses =
|
||||
"group-hover:border-cyan-400/70 dark:group-hover:border-cyan-500/50 group-hover:shadow-[0_0_15px_rgba(34,211,238,0.4)] dark:group-hover:shadow-[0_0_15px_rgba(34,211,238,0.6)]";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Drag-and-drop card with react-dnd - requires div for drag handle
|
||||
<div
|
||||
ref={(node) => drag(drop(node))}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`w-full min-h-[140px] cursor-move relative ${isDragging ? "opacity-50 scale-90" : "scale-100 opacity-100"} ${transitionStyles} group`}
|
||||
role="group"
|
||||
className={cn(
|
||||
"w-full min-h-[140px] cursor-move relative group",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
isDragging ? "opacity-50 scale-90" : "scale-100 opacity-100",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleTaskClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (onEdit) {
|
||||
onEdit(task);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} ${selectionGlow} ${optimistic ? "opacity-80 ring-1 ring-cyan-400/30" : ""} w-full min-h-[140px] h-full`}
|
||||
<Card
|
||||
blur="md"
|
||||
transparency="light"
|
||||
size="none"
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out",
|
||||
"w-full min-h-[140px] h-full",
|
||||
isHighlighted && "border-cyan-400/50 shadow-[0_0_8px_rgba(34,211,238,0.2)]",
|
||||
isSelected && "border-blue-500 shadow-[0_0_12px_rgba(59,130,246,0.4)]",
|
||||
"group-hover:border-cyan-400/70 dark:group-hover:border-cyan-500/50 group-hover:shadow-[0_0_15px_rgba(34,211,238,0.4)] dark:group-hover:shadow-[0_0_15px_rgba(34,211,238,0.6)]",
|
||||
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
|
||||
)}
|
||||
>
|
||||
{/* Priority indicator with beautiful glow */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-[3px] ${getOrderColor(task.task_order)} ${getOrderGlow(task.task_order)} rounded-l-lg opacity-80 group-hover:w-[4px] group-hover:opacity-100 transition-all duration-300`}
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-[3px] rounded-l-lg opacity-80 group-hover:w-[4px] group-hover:opacity-100 transition-all duration-300",
|
||||
getOrderColor(task.task_order),
|
||||
getOrderGlow(task.task_order),
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content container with fixed padding */}
|
||||
@@ -186,7 +180,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
<OptimisticIndicator isOptimistic={optimistic} className="ml-auto" />
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={`${optimistic ? "" : "ml-auto"} flex items-center gap-1.5`}>
|
||||
<div className={cn("flex items-center gap-1.5", !optimistic && "ml-auto")}>
|
||||
<TaskCardActions
|
||||
taskId={task.id}
|
||||
taskTitle={task.title}
|
||||
@@ -232,7 +226,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,10 +36,10 @@ export const getAssigneeGlow = (assigneeName: Assignee) => {
|
||||
|
||||
// Get color based on task priority/order
|
||||
export const getOrderColor = (order: number) => {
|
||||
if (order <= 3) return "bg-rose-500";
|
||||
if (order <= 6) return "bg-orange-500";
|
||||
if (order <= 10) return "bg-blue-500";
|
||||
return "bg-green-500";
|
||||
if (order <= 3) return "bg-rose-500 dark:bg-rose-400";
|
||||
if (order <= 6) return "bg-orange-500 dark:bg-orange-400";
|
||||
if (order <= 10) return "bg-blue-500 dark:bg-blue-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
};
|
||||
|
||||
// Get glow effect based on task priority/order
|
||||
@@ -68,12 +68,12 @@ export const getColumnColor = (status: "todo" | "doing" | "review" | "done") =>
|
||||
export const getColumnGlow = (status: "todo" | "doing" | "review" | "done") => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return "bg-gray-500/30";
|
||||
return "bg-gray-500/30 dark:bg-gray-400/40";
|
||||
case "doing":
|
||||
return "bg-blue-500/30 shadow-[0_0_10px_2px_rgba(59,130,246,0.2)]";
|
||||
return "bg-blue-500/30 dark:bg-blue-400/40 shadow-[0_0_10px_2px_rgba(59,130,246,0.2)] dark:shadow-[0_0_10px_2px_rgba(96,165,250,0.3)]";
|
||||
case "review":
|
||||
return "bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]";
|
||||
return "bg-purple-500/30 dark:bg-purple-400/40 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)] dark:shadow-[0_0_10px_2px_rgba(192,132,252,0.3)]";
|
||||
case "done":
|
||||
return "bg-green-500/30 shadow-[0_0_10px_2px_rgba(34,197,94,0.2)]";
|
||||
return "bg-green-500/30 dark:bg-green-400/40 shadow-[0_0_10px_2px_rgba(34,197,94,0.2)] dark:shadow-[0_0_10px_2px_rgba(74,222,128,0.3)]";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export const BoardView = ({
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[70vh] relative">
|
||||
{/* Board Columns Grid */}
|
||||
<div className="grid grid-cols-4 gap-1 flex-1 p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 flex-1 p-2 min-h-[500px]">
|
||||
{columns.map(({ status, title }) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
|
||||
@@ -2,13 +2,20 @@ import { Check, Edit, Tag, Trash2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../ui/primitives";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import { cn, glassmorphism } from "../../../ui/primitives/styles";
|
||||
import { EditableTableCell } from "../components/EditableTableCell";
|
||||
import { TaskAssignee } from "../components/TaskAssignee";
|
||||
import { useDeleteTask, useUpdateTask } from "../hooks";
|
||||
import type { Assignee, Task } from "../types";
|
||||
import { getOrderColor, getOrderGlow, ItemTypes } from "../utils/task-styles";
|
||||
|
||||
const rowVariants = {
|
||||
even: "bg-white/50 dark:bg-black/50",
|
||||
odd: "bg-gray-50/80 dark:bg-gray-900/30",
|
||||
hover:
|
||||
"hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70 dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20",
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
interface TableViewProps {
|
||||
tasks: Task[];
|
||||
projectId: string;
|
||||
@@ -114,11 +121,9 @@ const DraggableRow = ({
|
||||
<tr
|
||||
ref={(node) => drag(drop(node))}
|
||||
className={cn(
|
||||
"group transition-all duration-200 cursor-move",
|
||||
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
|
||||
"hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70",
|
||||
"dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20",
|
||||
"border-b border-gray-200 dark:border-gray-800",
|
||||
"group transition-all duration-200 cursor-move border-b border-gray-200 dark:border-gray-800",
|
||||
index % 2 === 0 ? rowVariants.even : rowVariants.odd,
|
||||
rowVariants.hover,
|
||||
isDragging && "opacity-50 scale-105 shadow-lg",
|
||||
isOver && "bg-cyan-100/50 dark:bg-cyan-900/20 border-cyan-400",
|
||||
)}
|
||||
@@ -178,8 +183,8 @@ const DraggableRow = ({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="xs" onClick={handleEdit} className="h-7 w-7 p-0">
|
||||
<Edit className="w-3 h-3" />
|
||||
<Button variant="ghost" size="xs" onClick={handleEdit} className="h-7 w-7 p-0" aria-label="Edit task">
|
||||
<Edit className="w-3 h-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit task</TooltipContent>
|
||||
@@ -192,8 +197,9 @@ const DraggableRow = ({
|
||||
size="xs"
|
||||
onClick={handleComplete}
|
||||
className="h-7 w-7 p-0 text-green-600 hover:text-green-700"
|
||||
aria-label="Mark task as complete"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<Check className="w-3 h-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark as complete</TooltipContent>
|
||||
@@ -207,8 +213,9 @@ const DraggableRow = ({
|
||||
onClick={handleDelete}
|
||||
className="h-7 w-7 p-0 text-red-600 hover:text-red-700"
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
aria-label="Delete task"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete task</TooltipContent>
|
||||
@@ -255,7 +262,7 @@ export const TableView = ({
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-b-2 border-gray-200 dark:border-gray-700">
|
||||
<tr className={cn(glassmorphism.background.card, "border-b-2 border-gray-200 dark:border-gray-700")}>
|
||||
<th className="w-1"></th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Title</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Status</th>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, CheckCircle2, FileText, LayoutGrid, List, ListTodo, Pin } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
|
||||
import { isOptimistic } from "../../shared/utils/optimistic";
|
||||
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
|
||||
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
|
||||
import { Button, PillNavigation, SelectableCard } from "../../ui/primitives";
|
||||
import { StatPill } from "../../ui/primitives/pill";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { NewProjectModal } from "../components/NewProjectModal";
|
||||
import { ProjectHeader } from "../components/ProjectHeader";
|
||||
import { ProjectList } from "../components/ProjectList";
|
||||
@@ -44,6 +49,9 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
|
||||
// State management
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("tasks");
|
||||
const [layoutMode, setLayoutMode] = useState<"horizontal" | "sidebar">("horizontal");
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<{
|
||||
@@ -59,14 +67,20 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
|
||||
const updateProjectMutation = useUpdateProject();
|
||||
const deleteProjectMutation = useDeleteProject();
|
||||
|
||||
// Sort projects - pinned first, then alphabetically
|
||||
// Sort and filter projects
|
||||
const sortedProjects = useMemo(() => {
|
||||
return [...(projects as Project[])].sort((a, b) => {
|
||||
// Filter by search query
|
||||
const filtered = (projects as Project[]).filter((project) =>
|
||||
project.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort: pinned first, then alphabetically
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [projects]);
|
||||
}, [projects, searchQuery]);
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectSelect = useCallback(
|
||||
@@ -165,51 +179,142 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
|
||||
initial="hidden"
|
||||
animate={isVisible ? "visible" : "hidden"}
|
||||
variants={containerVariants}
|
||||
className={`max-w-full mx-auto ${className}`}
|
||||
className={cn("max-w-full mx-auto", className)}
|
||||
data-id={dataId}
|
||||
>
|
||||
<ProjectHeader onNewProject={() => setIsNewProjectModalOpen(true)} />
|
||||
|
||||
<ProjectList
|
||||
projects={sortedProjects}
|
||||
selectedProject={selectedProject}
|
||||
taskCounts={taskCounts}
|
||||
isLoading={isLoadingProjects}
|
||||
error={projectsError as Error | null}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onPinProject={handlePinProject}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onRetry={() => queryClient.invalidateQueries({ queryKey: projectKeys.lists() })}
|
||||
<ProjectHeader
|
||||
onNewProject={() => setIsNewProjectModalOpen(true)}
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={setLayoutMode}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Project Details Section */}
|
||||
{selectedProject && (
|
||||
<motion.div variants={itemVariants} className="relative">
|
||||
<Tabs defaultValue="tasks" value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="docs" className="py-3 font-mono transition-all duration-300" color="blue">
|
||||
Docs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="py-3 font-mono transition-all duration-300" color="orange">
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{layoutMode === "horizontal" ? (
|
||||
<>
|
||||
<ProjectList
|
||||
projects={sortedProjects}
|
||||
selectedProject={selectedProject}
|
||||
taskCounts={taskCounts}
|
||||
isLoading={isLoadingProjects}
|
||||
error={projectsError as Error | null}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onPinProject={handlePinProject}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onRetry={() => queryClient.invalidateQueries({ queryKey: projectKeys.lists() })}
|
||||
/>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === "docs" && (
|
||||
<TabsContent value="docs" className="mt-0">
|
||||
<DocsTab project={selectedProject} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{activeTab === "tasks" && (
|
||||
<TabsContent value="tasks" className="mt-0">
|
||||
<TasksTab projectId={selectedProject.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{/* Project Details Section */}
|
||||
{selectedProject && (
|
||||
<motion.div variants={itemVariants} className="relative">
|
||||
{/* PillNavigation centered, View Toggle on right */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
<PillNavigation
|
||||
items={[
|
||||
{ id: "docs", label: "Docs", icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: "tasks", label: "Tasks", icon: <ListTodo className="w-4 h-4" /> },
|
||||
]}
|
||||
activeSection={activeTab}
|
||||
onSectionClick={(id) => setActiveTab(id as string)}
|
||||
colorVariant="orange"
|
||||
size="small"
|
||||
showIcons={true}
|
||||
showText={true}
|
||||
hasSubmenus={false}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === "docs" && <DocsTab project={selectedProject} />}
|
||||
{activeTab === "tasks" && <TasksTab projectId={selectedProject.id} />}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Sidebar Mode */
|
||||
<div className="flex gap-6">
|
||||
{/* Left Sidebar - Collapsible Project List */}
|
||||
{sidebarExpanded && (
|
||||
<div className="w-64 flex-shrink-0 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-white">Projects</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarExpanded(false)}
|
||||
className="px-2"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-expanded={sidebarExpanded}
|
||||
>
|
||||
<List className="w-3 h-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedProjects.map((project) => (
|
||||
<SidebarProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
isSelected={selectedProject?.id === project.id}
|
||||
taskCounts={taskCounts[project.id] || { todo: 0, doing: 0, review: 0, done: 0 }}
|
||||
onSelect={() => handleProjectSelect(project)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - CRITICAL: min-w-0 prevents page expansion */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedProject && (
|
||||
<>
|
||||
{/* Header with project name, tabs, view toggle inline */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{!sidebarExpanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarExpanded(true)}
|
||||
className="px-2 flex-shrink-0"
|
||||
aria-label="Expand sidebar"
|
||||
aria-expanded={sidebarExpanded}
|
||||
>
|
||||
<List className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
<span className="text-sm font-medium">{selectedProject.title}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* PillNavigation - ALWAYS CENTERED */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
<PillNavigation
|
||||
items={[
|
||||
{ id: "docs", label: "Docs", icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: "tasks", label: "Tasks", icon: <ListTodo className="w-4 h-4" /> },
|
||||
]}
|
||||
activeSection={activeTab}
|
||||
onSectionClick={(id) => setActiveTab(id as string)}
|
||||
colorVariant="orange"
|
||||
size="small"
|
||||
showIcons={true}
|
||||
showText={true}
|
||||
hasSubmenus={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
{activeTab === "docs" && <DocsTab project={selectedProject} />}
|
||||
{activeTab === "tasks" && <TasksTab projectId={selectedProject.id} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
@@ -232,3 +337,77 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sidebar Project Card - compact variant with StatPills
|
||||
interface SidebarProjectCardProps {
|
||||
project: Project;
|
||||
isSelected: boolean;
|
||||
taskCounts: {
|
||||
todo: number;
|
||||
doing: number;
|
||||
review: number;
|
||||
done: number;
|
||||
};
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const SidebarProjectCard: React.FC<SidebarProjectCardProps> = ({ project, isSelected, taskCounts, onSelect }) => {
|
||||
const optimistic = isOptimistic(project);
|
||||
|
||||
const getBackgroundClass = () => {
|
||||
if (project.pinned)
|
||||
return "bg-gradient-to-b from-purple-100/80 via-purple-50/30 to-purple-100/50 dark:from-purple-900/30 dark:via-purple-900/20 dark:to-purple-900/10";
|
||||
if (isSelected)
|
||||
return "bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20";
|
||||
return "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30";
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
isSelected={isSelected}
|
||||
isPinned={project.pinned}
|
||||
showAuroraGlow={isSelected}
|
||||
onSelect={onSelect}
|
||||
size="none"
|
||||
blur="md"
|
||||
className={cn("p-2", getBackgroundClass(), optimistic && "opacity-80 ring-1 ring-cyan-400/30")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{/* Title */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4
|
||||
className={cn(
|
||||
"font-medium text-sm line-clamp-1 flex-1",
|
||||
isSelected ? "text-purple-700 dark:text-purple-300" : "text-gray-700 dark:text-gray-300",
|
||||
)}
|
||||
>
|
||||
{project.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{project.pinned && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 bg-purple-500 dark:bg-purple-600 text-white text-[9px] font-bold rounded-full"
|
||||
aria-label="Pinned"
|
||||
>
|
||||
<Pin className="w-2.5 h-2.5" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<OptimisticIndicator isOptimistic={optimistic} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Pills - horizontal layout with icons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatPill color="pink" value={taskCounts.todo} size="sm" icon={<ListTodo className="w-3 h-3" />} />
|
||||
<StatPill
|
||||
color="blue"
|
||||
value={taskCounts.doing + taskCounts.review}
|
||||
size="sm"
|
||||
icon={<Activity className="w-3 h-3" />}
|
||||
/>
|
||||
<StatPill color="green" value={taskCounts.done} size="sm" icon={<CheckCircle2 className="w-3 h-3" />} />
|
||||
</div>
|
||||
</div>
|
||||
</SelectableCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export * from "./grouped-card";
|
||||
export * from "./input";
|
||||
export * from "./inspector-dialog";
|
||||
export * from "./pill";
|
||||
export * from "./pill-navigation";
|
||||
export * from "./select";
|
||||
export * from "./selectable-card";
|
||||
// Export style utilities
|
||||
|
||||
Reference in New Issue
Block a user