Merge pull request #777 from coleam00/refactor/projects-ui

Refactor the UI and add Documents back.
This commit is contained in:
sean-eskerium
2025-10-10 21:58:25 -04:00
committed by GitHub
19 changed files with 1247 additions and 454 deletions

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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) => (

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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");
},
});
}

View File

@@ -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",
},
);
},
};

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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)]";
}
};

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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