feat: add task comments feature with CRUD operations

- Introduced a new `task_comments` table in the database schema.
- Implemented task comments API endpoints for listing, creating, and deleting comments.
- Enhanced the task detail dialog to display comments and allow users to add new comments.
- Updated task row component to handle click events for viewing task details.
- Added a theme provider to manage light/dark mode across the application.
- Refactored Milkdown editor to use Crepe for improved markdown editing experience.
- Updated global styles to accommodate new editor and theme changes.
- Enhanced task filtering and sorting functionality in the tasks page.
This commit is contained in:
Roberto Musso
2026-02-23 12:54:14 +01:00
parent 98acf6220e
commit c1aa6829c9
24 changed files with 996 additions and 234 deletions

View File

@@ -1,6 +1,36 @@
import { Sparkles } from 'lucide-react';
import { Sparkles, KeyRound } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
interface AIChatPanelProps {
onOpenSettings?: () => void;
}
export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
if (hasTokenQuery.data === false) {
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
<Card className="max-w-sm">
<CardContent className="flex flex-col items-center gap-4 pt-6">
<KeyRound size={32} className="text-muted-foreground" />
<div className="text-center space-y-1">
<p className="text-sm font-medium">AI provider not configured</p>
<p className="text-xs text-muted-foreground">
Connect your GitHub Copilot token to enable AI-powered features like chat, summaries, and suggestions.
</p>
</div>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings
</Button>
</CardContent>
</Card>
</div>
);
}
export function AIChatPanel() {
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
<Sparkles size={32} className="text-muted-foreground/40 mb-3" />

View File

@@ -9,6 +9,13 @@ import {
PanelLeft,
ChevronUp,
ChevronDown,
Settings,
Sparkles,
Check,
Sun,
Moon,
Monitor,
Palette
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import {
@@ -25,7 +32,30 @@ import {
SidebarProvider,
useSidebar,
} from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { useTheme } from '@/components/theme-provider';
const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' },
@@ -72,6 +102,21 @@ export function AppShell({ children }: AppShellProps) {
setSidebarCollapsedMutation.mutate({ collapsed: !value });
};
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [saved, setSaved] = useState(false);
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const utils = trpc.useUtils();
const setTokenMutation = trpc.ai.setToken.useMutation({
onSuccess: () => {
setSaved(true);
setTokenInput('');
void utils.ai.hasToken.invalidate();
setTimeout(() => setSaved(false), 2000);
},
});
// Curtain is disabled on home page and on /projects without a selected project
const searchObj = routerState.location.search as Record<string, unknown>;
const curtainEnabled =
@@ -141,11 +186,15 @@ export function AppShell({ children }: AppShellProps) {
}, [openCurtain, closeCurtain]);
return (
<>
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar currentPath={currentPath} />
<AppSidebar
currentPath={currentPath}
setTokenDialogOpen={setTokenDialogOpen}
/>
<SidebarInset className="overflow-hidden">
{/* AI Chat layer: always mounted behind the content panel */}
<AIChatPanel />
<AIChatPanel onOpenSettings={() => setTokenDialogOpen(true)} />
{/* Content panel: slides down to reveal chat */}
<motion.div
@@ -158,12 +207,12 @@ export function AppShell({ children }: AppShellProps) {
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
<div className="flex flex-col items-center gap-1.5 pr-2">
{curtainOpen ? (
<ChevronDown size={10} className="text-muted-foreground/30" />
<ChevronDown size={10} />
) : (
<ChevronUp size={10} className="text-muted-foreground/30" />
<ChevronUp size={10} />
)}
<span
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
className="text-[9px] tracking-widest uppercase font-medium"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
@@ -173,11 +222,62 @@ export function AppShell({ children }: AppShellProps) {
</motion.div>
</SidebarInset>
</SidebarProvider>
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
setTokenDialogOpen(open);
if (!open) { setTokenInput(''); setSaved(false); }
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI Provider</DialogTitle>
<DialogDescription>
Configure your AI provider credentials for chat, summaries, and suggestions.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium">GitHub Copilot Token</label>
<Input
type="password"
placeholder="Paste your token here"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Your token is stored securely in the OS keychain.
{hasTokenQuery.data === true && (
<span className="text-green-600 ml-1">A token is currently stored.</span>
)}
</p>
</div>
<DialogFooter>
{saved && (
<span className="flex items-center gap-1 text-sm text-green-600 mr-auto">
<Check size={14} />
Saved
</span>
)}
<Button
disabled={!tokenInput.trim() || setTokenMutation.isPending}
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
>
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function AppSidebar({ currentPath }: { currentPath: string }) {
interface AppSidebarProps {
currentPath: string;
setTokenDialogOpen: (open: boolean) => void;
}
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
const { toggleSidebar } = useSidebar();
const { theme, setTheme } = useTheme();
return (
<Sidebar collapsible="icon">
@@ -241,9 +341,50 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
</SidebarGroup>
</SidebarContent>
{/* Collapse toggle — spec: useSidebar() + custom trigger */}
{/* Settings gear + Collapse toggle */}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip="Settings">
<Settings />
<span>Settings</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end" className="w-56">
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
<Sparkles className="mr-2 size-4" />
AI Provider
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 size-4" />
<span>Theme</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => setTheme('light')}>
<Sun className="mr-2 size-4" />
Light
{theme === 'light' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('dark')}>
<Moon className="mr-2 size-4" />
Dark
{theme === 'dark' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('system')}>
<Monitor className="mr-2 size-4" />
System
{theme === 'system' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
<PanelLeft />
@@ -252,6 +393,7 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,47 +1,49 @@
import { useRef } from 'react';
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/kit/core';
import { commonmark } from '@milkdown/kit/preset/commonmark';
import { history } from '@milkdown/kit/plugin/history';
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { useEffect, useRef } from 'react';
import { Crepe, CrepeFeature } from '@milkdown/crepe';
import '@milkdown/kit/prose/view/style/prosemirror.css';
import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/nord.css';
interface MilkdownEditorProps {
initialContent: string;
onChange: (markdown: string) => void;
}
function MilkdownInner({ initialContent, onChange }: MilkdownEditorProps) {
export function MilkdownEditor({ initialContent, onChange }: MilkdownEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const crepeRef = useRef<Crepe | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialContent);
})
.use(commonmark)
.use(history)
.use(listener)
.config((ctx) => {
ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
onChangeRef.current(markdown);
}
});
}),
[]
);
useEffect(() => {
if (!containerRef.current) return;
return <Milkdown />;
}
const crepe = new Crepe({
root: containerRef.current,
defaultValue: initialContent,
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: 'Start writing...',
},
},
});
export function MilkdownEditor(props: MilkdownEditorProps) {
return (
<MilkdownProvider>
<MilkdownInner {...props} />
</MilkdownProvider>
);
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
onChangeRef.current(markdown);
}
});
});
crepe.create();
crepeRef.current = crepe;
return () => {
crepe.destroy();
crepeRef.current = null;
};
}, []);
return <div ref={containerRef} className="milkdown-container" />;
}

View File

@@ -0,0 +1,274 @@
import { useState } from 'react';
import {
Calendar,
User,
CircleDot,
FolderOpen,
Zap,
Pencil,
Trash2,
Send,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { trpc } from '@/lib/trpc';
import { PriorityBadge } from './PriorityBadge';
import { parseAssignees, type TaskItem } from './TaskRow';
function formatDate(timestamp: number): string {
const d = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`;
}
function relativeTime(timestamp: number): string {
const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hr ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
todo: { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' },
in_progress: { label: 'In Progress', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
done: { label: 'Done', className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
};
function AuthorAvatar({ name }: { name: string }) {
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('');
return (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
{initials}
</div>
);
}
interface TaskDetailDialogProps {
task: TaskItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: (task: TaskItem) => void;
onDelete: (id: string) => void;
}
export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }: TaskDetailDialogProps) {
const [commentText, setCommentText] = useState('');
const [activeTab, setActiveTab] = useState('description');
const { data: comments } = trpc.taskComments.list.useQuery(
{ taskId: task?.id ?? '' },
{ enabled: !!task },
);
const utils = trpc.useUtils();
const addComment = trpc.taskComments.create.useMutation({
onSuccess: () => {
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
setCommentText('');
},
});
const deleteComment = trpc.taskComments.delete.useMutation({
onSuccess: () => {
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
},
});
if (!task) return null;
const assignees = parseAssignees(task.assignee);
const statusConf = STATUS_CONFIG[task.status ?? 'todo'] ?? { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
const breadcrumb = [task.clientName, task.subClientName, task.projectName].filter(Boolean);
const handleAddComment = () => {
const text = commentText.trim();
if (!text) return;
addComment.mutate({ taskId: task.id, author: 'Me', content: text });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[620px] gap-0 p-0" aria-describedby={undefined}>
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="text-lg font-semibold leading-tight">{task.title}</DialogTitle>
</DialogHeader>
<Separator />
{/* Field rows */}
<div className="grid grid-cols-[120px_1fr] gap-y-3 px-6 py-4 text-sm">
{/* Assignee */}
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
Assignee
</div>
<div className="flex flex-wrap items-center gap-1.5">
{assignees.length > 0 ? (
assignees.map((name) => (
<Badge key={name} variant="secondary" className="text-xs">
{name}
</Badge>
))
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</div>
{/* Status */}
<div className="flex items-center gap-2 text-muted-foreground">
<CircleDot className="h-4 w-4" />
Status
</div>
<div>
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConf.className}`}>
{statusConf.label}
</span>
</div>
{/* Due date */}
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
Due date
</div>
<div>
{task.dueDate ? formatDate(task.dueDate) : <span className="text-muted-foreground">No due date</span>}
</div>
{/* Priority */}
<div className="flex items-center gap-2 text-muted-foreground">
<Zap className="h-4 w-4" />
Priority
</div>
<div>
<PriorityBadge priority={task.priority} />
</div>
{/* Project */}
{breadcrumb.length > 0 && (
<>
<div className="flex items-center gap-2 text-muted-foreground">
<FolderOpen className="h-4 w-4" />
Project
</div>
<div className="text-sm">{breadcrumb.join(' > ')}</div>
</>
)}
</div>
<Separator />
{/* Tabs: Description / Comment */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<TabsList className="mx-6 mt-3 w-fit">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="comment">Comment</TabsTrigger>
</TabsList>
<TabsContent value="description" className="px-6 py-4 min-h-[120px]">
{task.description ? (
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
</TabsContent>
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
{/* Comment list */}
<div className="flex flex-col gap-4 max-h-[260px] overflow-y-auto">
{(!comments || comments.length === 0) ? (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
) : (
comments.map((c) => (
<div key={c.id} className="flex gap-3">
<AuthorAvatar name={c.author} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{c.author}</span>
<span className="text-xs text-muted-foreground">{relativeTime(c.createdAt)}</span>
</div>
<div className="rounded-lg bg-muted px-3 py-2 text-sm">
{c.content}
</div>
<div className="flex items-center gap-3 mt-1">
<button
type="button"
className="text-xs text-muted-foreground hover:text-destructive"
onClick={() => deleteComment.mutate({ id: c.id })}
>
Delete
</button>
</div>
</div>
</div>
))
)}
</div>
{/* Add comment input */}
<form
className="flex items-center gap-2 mt-auto"
onSubmit={(e) => { e.preventDefault(); handleAddComment(); }}
>
<AuthorAvatar name="Me" />
<Input
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="flex-1"
/>
<Button
type="submit"
size="icon"
variant="ghost"
disabled={!commentText.trim() || addComment.isPending}
>
<Send className="h-4 w-4" />
</Button>
</form>
</TabsContent>
</Tabs>
<Separator />
{/* Footer */}
<DialogFooter className="px-6 py-4">
<Button
variant="destructive"
size="sm"
onClick={() => { onDelete(task.id); onOpenChange(false); }}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
<Button
size="sm"
onClick={() => { onEdit(task); onOpenChange(false); }}
>
<Pencil className="h-4 w-4 mr-1" />
Edit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -41,7 +41,11 @@ export function parseAssignees(raw: string | null): string[] {
function formatDueDate(timestamp: number): string {
const d = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `Due ${months[d.getMonth()]} ${d.getDate()}`;
const date = `Due ${months[d.getMonth()]} ${d.getDate()}`;
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${date}, ${h}:${m}`;
}
export function TaskRow({
@@ -49,12 +53,14 @@ export function TaskRow({
onToggle,
onEdit,
onDelete,
onClick,
hideBreadcrumb,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
onEdit?: (task: TaskItem) => void;
onDelete?: (id: string) => void;
onClick?: (task: TaskItem) => void;
hideBreadcrumb?: boolean;
}) {
const isDone = task.status === 'done';
@@ -80,15 +86,17 @@ export function TaskRow({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border cursor-default select-none ${
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
}`}
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
onClick={() => onClick?.(task)}
>
{/* Row 1: checkbox + title + description */}
<div className="flex items-start gap-3">
<Checkbox
checked={checkboxState}
onCheckedChange={() => onToggle(task.id, task.status)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "adiuva-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -141,141 +141,47 @@ body {
overflow: hidden;
}
/* Milkdown editor overrides */
[data-milkdown-root] {
/* Crepe editor layout */
.milkdown-container {
display: flex;
flex-direction: column;
height: 100%;
}
.milkdown {
display: flex;
flex-direction: column;
.milkdown-container .milkdown {
flex: 1;
min-height: 0;
color: var(--foreground);
font-family: inherit;
line-height: 1.75;
--crepe-color-background: var(--background);
--crepe-font-default: 'Geist', 'Inter', system-ui, sans-serif;
--crepe-font-title: 'Geist', 'Inter', system-ui, sans-serif;
}
.milkdown .editor {
/* Override Crepe's default 60px 120px padding for panel use.
Left padding >=72px to leave room for the block handle (plus + drag buttons). */
.milkdown-container .milkdown .ProseMirror {
@apply pr-6 pl-18 py-0;
flex: 1;
outline: none;
padding: 0;
overflow-y: auto;
word-break: break-word;
overflow-wrap: break-word;
}
.milkdown .editor > * + * {
margin-top: 0.75em;
}
.milkdown .editor h1 {
font-size: 1.875rem;
font-weight: 700;
line-height: 1.2;
color: var(--foreground);
}
.milkdown .editor h2 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
color: var(--foreground);
}
.milkdown .editor h3 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
color: var(--foreground);
}
.milkdown .editor h4 {
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground);
}
.milkdown .editor h5,
.milkdown .editor h6 {
font-size: 1rem;
font-weight: 600;
color: var(--foreground);
}
.milkdown .editor p {
color: var(--foreground);
}
.milkdown .editor blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
color: var(--muted-foreground);
font-style: italic;
}
.milkdown .editor pre {
background: var(--muted);
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem;
}
.milkdown .editor code {
background: var(--muted);
color: var(--foreground);
padding: 0.125rem 0.375rem;
border-radius: calc(var(--radius) - 4px);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875em;
}
.milkdown .editor pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
.milkdown .editor ul,
.milkdown .editor ol {
padding-left: 1.5rem;
color: var(--foreground);
}
.milkdown .editor ul {
list-style-type: disc;
}
.milkdown .editor ol {
list-style-type: decimal;
}
.milkdown .editor li + li {
margin-top: 0.25em;
}
.milkdown .editor a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.milkdown .editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
.milkdown .editor strong {
font-weight: 600;
}
.milkdown .editor em {
font-style: italic;
/* Dark theme: scope nord-dark variables under .dark class */
.dark .milkdown {
--crepe-color-on-background: #f8f9ff;
--crepe-color-surface: #111418;
--crepe-color-surface-low: #191c20;
--crepe-color-on-surface: #e1e2e8;
--crepe-color-on-surface-variant: #c3c6cf;
--crepe-color-outline: #8d9199;
--crepe-color-primary: #a1c9fd;
--crepe-color-secondary: #3c4858;
--crepe-color-on-secondary: #d7e3f8;
--crepe-color-inverse: #e1e2e8;
--crepe-color-on-inverse: #2e3135;
--crepe-color-inline-code: #ffb4ab;
--crepe-color-error: #ffb4ab;
--crepe-color-hover: #1d2024;
--crepe-color-selected: #32353a;
--crepe-color-inline-area: #111418;
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
}

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ipcLink } from './lib/ipcLink';
import { router } from './router';
import { trpc } from './lib/trpc';
import { ThemeProvider } from './components/theme-provider';
import './globals.css';
function App() {
@@ -16,11 +17,13 @@ function App() {
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</trpc.Provider>
<ThemeProvider defaultTheme="system" storageKey="adiuva-theme">
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</trpc.Provider>
</ThemeProvider>
);
}

View File

@@ -23,6 +23,7 @@ import {
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
export const Route = createFileRoute('/tasks')({
@@ -42,9 +43,10 @@ function TasksPage() {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
const [dialogOpen, setDialogOpen] = useState(false);
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
@@ -127,7 +129,7 @@ function TasksPage() {
<ItemDescription>To Do</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" className="bg-sky-50">
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
<ItemMedia variant="icon">
<Loader2 />
</ItemMedia>
@@ -136,7 +138,7 @@ function TasksPage() {
<ItemDescription>In Progress</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" className="bg-green-50">
<Item variant="muted" className="bg-green-50 dark:bg-green-950/30">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
@@ -211,6 +213,7 @@ function TasksPage() {
onToggle={handleCheckboxToggle}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask}
/>
))
)}
@@ -222,6 +225,13 @@ function TasksPage() {
open={!!editTask}
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
/>
<TaskDetailDialog
task={viewTask}
open={!!viewTask}
onOpenChange={(open) => { if (!open) setViewTask(null); }}
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
/>
</div>
);
}

View File

@@ -102,7 +102,7 @@ function TimelinePage() {
No checkpoints yet. Click "+ Add" to create your first milestone.
</div>
) : (
<div className="border rounded-md p-4 bg-white">
<div className="border rounded-md p-4 bg-card">
<GanttChart
checkpoints={ganttCheckpoints}
startDate={startDate}