refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories - Add Dockerfiles for web, worker, and PocketBase services - Add docker-compose.yml for local orchestration - Add turbo.json for monorepo task management - Add Playwright e2e test infrastructure - Add PocketBase backend with migrations - Remove Vite/Next.js/ESLint/PostCSS config files - Update package.json with workspace dependencies - Add .env.example and .dockerignore
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Flame,
|
||||
BarChart3,
|
||||
PieChart as PieChartIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface TimeData {
|
||||
date: string;
|
||||
tasks: number;
|
||||
habits: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface DomainData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HabitData {
|
||||
name: string;
|
||||
streak: number;
|
||||
score: number;
|
||||
consistency: number;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
||||
|
||||
const chartTooltipStyle = {
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
|
||||
interface AnalyticsChartsProps {
|
||||
timeData: TimeData[];
|
||||
domainData: DomainData[];
|
||||
habitData: HabitData[];
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
export function AnalyticsCharts({
|
||||
timeData,
|
||||
domainData,
|
||||
habitData,
|
||||
activeTab,
|
||||
}: AnalyticsChartsProps) {
|
||||
if (activeTab === 'trends') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Productivity trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" aria-hidden="true" />
|
||||
Productivity Trend
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={timeData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="tasks"
|
||||
stackId="1"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.6}
|
||||
name="Tasks Completed"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="habits"
|
||||
stackId="1"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.6}
|
||||
name="Habits Logged"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time tracked trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" aria-hidden="true" />
|
||||
Time Tracked
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={timeData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
label={{
|
||||
value: 'Minutes',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
}}
|
||||
/>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="time"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#8b5cf6', r: 3 }}
|
||||
name="Time (minutes)"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'habits') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Habit streaks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5" aria-hidden="true" />
|
||||
Habit Streaks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{habitData.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No habits tracked
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={habitData} layout="vertical">
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Bar
|
||||
dataKey="streak"
|
||||
fill="#f59e0b"
|
||||
radius={[0, 4, 4, 0]}
|
||||
name="Current Streak (days)"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit scores */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Habit Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{habitData.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No habits tracked
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{habitData.map((habit) => (
|
||||
<div key={habit.name}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{habit.name}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{habit.score}/100
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${habit.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'time') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Time by domain */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChartIcon className="h-5 w-5" aria-hidden="true" />
|
||||
Time by Domain
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{domainData.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No time tracked
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-8">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={domainData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${((percent || 0) * 100).toFixed(0)}%`
|
||||
}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{domainData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" aria-hidden="true" />
|
||||
Daily Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={timeData.slice(-7)}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip contentStyle={chartTooltipStyle} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="tasks"
|
||||
fill="#3b82f6"
|
||||
name="Tasks"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="habits"
|
||||
fill="#10b981"
|
||||
name="Habits"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import { format, parse, startOfWeek, getDay } from 'date-fns';
|
||||
import { enUS } from 'date-fns/locale/en-US';
|
||||
|
||||
const locales = {
|
||||
'en-US': enUS,
|
||||
};
|
||||
|
||||
const localizer = dateFnsLocalizer({
|
||||
format,
|
||||
parse,
|
||||
startOfWeek,
|
||||
getDay,
|
||||
locales,
|
||||
});
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||
domain: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface BigCalendarWrapperProps {
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
function eventStyleGetter(event: CalendarEvent) {
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: event.color,
|
||||
borderRadius: '4px',
|
||||
opacity: 0.8,
|
||||
color: 'white',
|
||||
border: '0px',
|
||||
fontSize: '12px',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleSelectEvent(event: CalendarEvent) {
|
||||
console.log('Selected event:', event);
|
||||
}
|
||||
|
||||
export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
||||
return (
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: 600 }}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
views={['month', 'week', 'day']}
|
||||
defaultView="month"
|
||||
popup
|
||||
toolbar
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Flame,
|
||||
FolderKanban,
|
||||
NotebookPen,
|
||||
FileBarChart,
|
||||
CalendarDays,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Settings,
|
||||
Plus,
|
||||
Search,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Tasks', href: '/tasks', icon: ListTodo },
|
||||
{ label: 'Habits', href: '/habits', icon: Flame },
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Notes', href: '/notes', icon: NotebookPen },
|
||||
{ label: 'Reports', href: '/reports', icon: FileBarChart },
|
||||
{ label: 'Calendar', href: '/calendar', icon: CalendarDays },
|
||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ label: 'Agent Activity', href: '/agents', icon: Bot },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deepSearch, setDeepSearch] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{
|
||||
type: string;
|
||||
items: Array<{ id: string; title: string }>;
|
||||
}>>([]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
setDeepSearch(true);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setDeepSearch(false);
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', down);
|
||||
return () => document.removeEventListener('keydown', down);
|
||||
}, []);
|
||||
|
||||
// Quick actions
|
||||
const quickActions: QuickAction[] = [
|
||||
{ label: 'New task', shortcut: 'N', action: () => router.push('/tasks?new=true') },
|
||||
{ label: 'New habit', action: () => router.push('/habits?new=true') },
|
||||
{ label: 'New project', action: () => router.push('/projects?new=true') },
|
||||
{ label: 'New note', action: () => router.push('/notes?new=true') },
|
||||
{ label: 'New report', action: () => router.push('/reports?new=true') },
|
||||
];
|
||||
|
||||
// Search handler
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results || []);
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runCommand = useCallback((command: () => void) => {
|
||||
setOpen(false);
|
||||
command();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label="Command palette"
|
||||
className={deepSearch ? 'max-w-2xl' : 'max-w-lg'}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={deepSearch ? 'Search everything...' : 'Type a command or search...'}
|
||||
onValueChange={handleSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{/* Navigation */}
|
||||
{!deepSearch && (
|
||||
<CommandGroup heading="Jump to">
|
||||
{navItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.href}
|
||||
onSelect={() => runCommand(() => router.push(item.href))}
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<CommandGroup heading="Quick actions">
|
||||
{quickActions.map((action) => (
|
||||
<CommandItem
|
||||
key={action.label}
|
||||
onSelect={() => runCommand(action.action)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{action.label}
|
||||
{action.shortcut && (
|
||||
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{/* Search Results (deep search mode) */}
|
||||
{deepSearch && searchResults.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
{searchResults.map((group) => (
|
||||
<CommandGroup key={group.type} heading={group.type}>
|
||||
{group.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
const typeRoute =
|
||||
group.type === 'tasks' ? '/tasks' :
|
||||
group.type === 'habits' ? '/habits' :
|
||||
group.type === 'projects' ? '/projects' :
|
||||
group.type === 'notes' ? '/notes' :
|
||||
'/reports';
|
||||
runCommand(() => router.push(`${typeRoute}/${item.id}`));
|
||||
}}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{item.title}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<kbd className="rounded border bg-muted px-1">↑↓</kbd> navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border bg-muted px-1">↵</kbd> select
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border bg-muted px-1">esc</kbd> close
|
||||
</span>
|
||||
</div>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import ReactGridLayout from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
// WidthProvider and Responsive are namespace exports from react-grid-layout.
|
||||
// With @types/react-grid-layout's `export =` pattern, we access them via the module.
|
||||
const WidthProvider = (
|
||||
ReactGridLayout as unknown as {
|
||||
WidthProvider: <P extends React.ComponentType<React.ComponentProps<P>>>(
|
||||
component: P
|
||||
) => React.ComponentType<React.ComponentProps<P> & { measureBeforeMount?: boolean }>;
|
||||
}
|
||||
).WidthProvider;
|
||||
|
||||
const Responsive = (
|
||||
ReactGridLayout as unknown as {
|
||||
Responsive: React.ComponentType<ReactGridLayout.ResponsiveProps>;
|
||||
}
|
||||
).Responsive;
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
interface ResponsiveGridProps {
|
||||
layout: ReactGridLayout.Layout[];
|
||||
onLayoutChange: (newLayout: ReactGridLayout.Layout[]) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ResponsiveGrid({
|
||||
layout,
|
||||
onLayoutChange,
|
||||
children,
|
||||
}: ResponsiveGridProps) {
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={{ lg: layout }}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={80}
|
||||
onLayoutChange={onLayoutChange}
|
||||
draggableHandle=".widget-drag-handle"
|
||||
compactType="vertical"
|
||||
isResizable
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function CalendarMiniWidget() {
|
||||
const today = new Date();
|
||||
const daysInMonth = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth() + 1,
|
||||
0
|
||||
).getDate();
|
||||
const firstDay = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
1
|
||||
).getDay();
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Calendar className="h-4 w-4" aria-hidden="true" />
|
||||
{today.toLocaleString('default', { month: 'long' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||
<div key={i} className="font-semibold text-muted-foreground">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: firstDay }).map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const isToday = day === today.getDate();
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`rounded p-1 ${
|
||||
isToday
|
||||
? 'bg-primary text-primary-foreground font-semibold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
current_streak: number;
|
||||
logged_today: boolean;
|
||||
}
|
||||
|
||||
export function HabitChecklistWidget() {
|
||||
const [habits, setHabits] = useState<Habit[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits();
|
||||
}, []);
|
||||
|
||||
async function fetchHabits() {
|
||||
try {
|
||||
const response = await fetch('/api/habits');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHabits(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch habits:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHabit(id: string) {
|
||||
try {
|
||||
await fetch(`/api/habits/${id}/logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
fetchHabits();
|
||||
} catch (error) {
|
||||
console.error('Failed to log habit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const completedCount = habits.filter((h) => h.logged_today).length;
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
Habits
|
||||
</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedCount}/{habits.length} done
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : habits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No habits tracked</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{habits.slice(0, 5).map((habit) => (
|
||||
<div key={habit.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={habit.id}
|
||||
checked={habit.logged_today}
|
||||
onCheckedChange={() => toggleHabit(habit.id)}
|
||||
aria-label={`Mark "${habit.name}" as ${habit.logged_today ? 'incomplete' : 'complete'}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={habit.id}
|
||||
className="flex-1 text-sm cursor-pointer"
|
||||
>
|
||||
{habit.name}
|
||||
</label>
|
||||
{habit.current_streak > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
🔥 {habit.current_streak}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface Streak {
|
||||
habit: { name: string };
|
||||
streak_current: number;
|
||||
}
|
||||
|
||||
export function HabitStreaksWidget() {
|
||||
const [streaks, setStreaks] = useState<Streak[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStreaks();
|
||||
}, []);
|
||||
|
||||
async function fetchStreaks() {
|
||||
try {
|
||||
const response = await fetch('/api/habits/streaks');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStreaks(data.streaks || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch streaks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
Top Streaks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : streaks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active streaks</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{streaks.slice(0, 5).map((streak, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<span className="text-sm">{streak.habit.name}</span>
|
||||
<span className="text-sm font-semibold">
|
||||
🔥 {streak.streak_current}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function ProjectProgressWidget() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'/api/projects?filter=status%3D%22active%22&perPage=5'
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjects(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FolderKanban className="h-4 w-4" aria-hidden="true" />
|
||||
Active Projects
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active projects</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm">{project.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function QuickAddWidget() {
|
||||
function handleQuickAdd() {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Quick Add
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={handleQuickAdd}
|
||||
>
|
||||
New task
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={handleQuickAdd}
|
||||
>
|
||||
New habit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={handleQuickAdd}
|
||||
>
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function RecentActivityWidget() {
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Activity feed coming soon
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Circle, ListTodo } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export function TodayTasksWidget() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'/api/tasks?filter=status!%3D%22done%22&perPage=5&sort=-priority'
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTask(id: string, currentStatus: string) {
|
||||
const newStatus = currentStatus === 'done' ? 'todo' : 'done';
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListTodo className="h-4 w-4" aria-hidden="true" />
|
||||
Today's Tasks
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No tasks for today</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
onClick={() => toggleTask(task.id, task.status)}
|
||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||
>
|
||||
{task.status === 'done' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span
|
||||
className={`flex-1 text-sm ${
|
||||
task.status === 'done'
|
||||
? 'line-through text-muted-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{task.domain}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BarChart3, TrendingUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface WeeklyStats {
|
||||
taskCompletionRate: number;
|
||||
habitConsistency: number;
|
||||
totalTimeMinutes: number;
|
||||
}
|
||||
|
||||
export function WeeklyStatsWidget() {
|
||||
const [stats, setStats] = useState<WeeklyStats>({
|
||||
taskCompletionRate: 0,
|
||||
habitConsistency: 0,
|
||||
totalTimeMinutes: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch('/api/analytics?period=7');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full border-0 shadow-none">
|
||||
<CardHeader className="p-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
This Week
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Task completion
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-semibold">
|
||||
{stats.taskCompletionRate}%
|
||||
</span>
|
||||
<TrendingUp className="h-3 w-3 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Habit consistency
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{stats.habitConsistency}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Time tracked
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{Math.round(stats.totalTimeMinutes / 60)}h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { Flame, CheckCircle2, Circle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
interface HabitCardProps {
|
||||
habit: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
frequency: 'daily' | 'weekly' | 'custom';
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
score: number;
|
||||
completion_mode: 'quick' | 'detailed';
|
||||
domain: string;
|
||||
logged_today: boolean;
|
||||
};
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function HabitCard({ habit, onComplete }: HabitCardProps) {
|
||||
return (
|
||||
<Card className="relative overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{habit.name}</CardTitle>
|
||||
{habit.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{habit.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="ml-2 shrink-0">
|
||||
{habit.domain}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Streak info */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
<span className="font-semibold">{habit.current_streak}</span>
|
||||
<span className="text-muted-foreground">day streak</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Best: {habit.best_streak}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Score</span>
|
||||
<span className="font-semibold">{habit.score}/100</span>
|
||||
</div>
|
||||
<Progress value={habit.score} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Frequency badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{habit.frequency}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{habit.completion_mode}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Complete button */}
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
variant={habit.logged_today ? 'outline' : 'default'}
|
||||
className="w-full"
|
||||
disabled={habit.logged_today}
|
||||
>
|
||||
{habit.logged_today ? (
|
||||
<>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4 text-green-600" />
|
||||
Completed today
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle className="mr-2 h-4 w-4" />
|
||||
Mark complete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HabitCompletionDialogProps {
|
||||
habit: Habit;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { mood?: number; value?: number; notes?: string }) => void;
|
||||
}
|
||||
|
||||
const moods = [
|
||||
{ value: 5, label: 'Great' },
|
||||
{ value: 4, label: 'Good' },
|
||||
{ value: 3, label: 'Okay' },
|
||||
{ value: 2, label: 'Meh' },
|
||||
{ value: 1, label: 'Bad' },
|
||||
];
|
||||
|
||||
export function HabitCompletionDialog({
|
||||
habit,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: HabitCompletionDialogProps) {
|
||||
const [mood, setMood] = useState<number | undefined>();
|
||||
const [quantity, setQuantity] = useState<number | undefined>();
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit({
|
||||
mood,
|
||||
value: quantity,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
setMood(undefined);
|
||||
setQuantity(undefined);
|
||||
setNotes('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log {habit.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
How did it go? (optional — you can skip and just log completion)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Mood picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>Mood</Label>
|
||||
<div className="flex gap-2">
|
||||
{moods.map((m) => (
|
||||
<Button
|
||||
key={m.value}
|
||||
variant={mood === m.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setMood(m.value)}
|
||||
className="flex-1"
|
||||
>
|
||||
{m.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">Quantity (optional)</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
placeholder="e.g., 30 minutes, 10 pages"
|
||||
value={quantity ?? ''}
|
||||
onChange={(e) =>
|
||||
setQuantity(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Any thoughts or reflections..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} className="flex-1">
|
||||
Log completion
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onSubmit({})}
|
||||
className="flex-1"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||
import type { Habit } from '@project-e/shared';
|
||||
|
||||
interface HeatmapValue {
|
||||
date: Date | string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface HabitHeatmapProps {
|
||||
habits: Habit[];
|
||||
}
|
||||
|
||||
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
const [values, setValues] = useState<HeatmapValue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHeatmapData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [habits.length]);
|
||||
|
||||
async function fetchHeatmapData() {
|
||||
try {
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const logs: Array<{ logged_at: string }> = data.items || [];
|
||||
|
||||
// Group by date
|
||||
const byDate: Record<string, number> = {};
|
||||
logs.forEach((log) => {
|
||||
const date = new Date(log.logged_at).toISOString().split('T')[0];
|
||||
byDate[date] = (byDate[date] || 0) + 1;
|
||||
});
|
||||
|
||||
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
|
||||
([date, count]) => ({
|
||||
date,
|
||||
count,
|
||||
})
|
||||
);
|
||||
|
||||
setValues(heatmapValues);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch heatmap data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<CalendarHeatmap
|
||||
startDate={oneYearAgo}
|
||||
endDate={today}
|
||||
values={values}
|
||||
classForValue={(value) => {
|
||||
if (!value || value.count === 0) return 'heatmap-empty';
|
||||
if (value.count <= 1) return 'heatmap-scale-1';
|
||||
if (value.count <= 2) return 'heatmap-scale-2';
|
||||
if (value.count <= 3) return 'heatmap-scale-3';
|
||||
return 'heatmap-scale-4';
|
||||
}}
|
||||
tooltipDataAttrs={(value) => {
|
||||
if (!value || value.count === 0) return null;
|
||||
const date = new Date(value.date).toLocaleDateString();
|
||||
return {
|
||||
'data-tip': `${date}: ${value.count} habit${value.count === 1 ? '' : 's'}`,
|
||||
};
|
||||
}}
|
||||
showWeekdayLabels
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { ShortcutsHelp } from '@/components/shortcuts-help';
|
||||
|
||||
export function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) {
|
||||
useKeyboardShortcuts();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ShortcutsHelp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function NetworkErrorBanner() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
|
||||
window.addEventListener('offline', handleOffline);
|
||||
window.addEventListener('online', handleOnline);
|
||||
|
||||
setIsOffline(!navigator.onLine);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-50 flex items-center justify-center gap-3 bg-destructive px-4 py-2 text-destructive-foreground shadow-lg"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="text-sm font-medium">
|
||||
You're offline. Some features may not work.
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" aria-hidden="true" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { format, addDays, subDays } from 'date-fns';
|
||||
|
||||
interface DailyNoteButtonProps {
|
||||
/** Called after the daily note is created/retrieved, with the raw PocketBase record. */
|
||||
onNoteReady: (note: Record<string, unknown>) => void;
|
||||
/** Optional: currently selected date (controls the displayed date). */
|
||||
selectedDate?: Date;
|
||||
/** Called when the user navigates to a different date. */
|
||||
onDateChange?: (date: Date) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button row that creates / navigates daily notes.
|
||||
*
|
||||
* Layout: ◀ [CalendarDays · 2026-07-15] ▶
|
||||
*
|
||||
* Clicking the centre button POSTs to /api/notes/daily and opens the note.
|
||||
* The arrow buttons shift the date by one day without fetching.
|
||||
*/
|
||||
export function DailyNoteButton({
|
||||
onNoteReady,
|
||||
selectedDate,
|
||||
onDateChange,
|
||||
}: DailyNoteButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentDate, setCurrentDate] = useState<Date>(
|
||||
selectedDate ?? new Date()
|
||||
);
|
||||
|
||||
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||
const displayDate = format(currentDate, 'MMM d, yyyy');
|
||||
|
||||
const navigate = useCallback(
|
||||
(delta: number) => {
|
||||
const next = delta > 0 ? addDays(currentDate, 1) : subDays(currentDate, 1);
|
||||
setCurrentDate(next);
|
||||
onDateChange?.(next);
|
||||
},
|
||||
[currentDate, onDateChange]
|
||||
);
|
||||
|
||||
async function handleCreateDailyNote() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/notes/daily', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: dateStr }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to create daily note', await res.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await res.json();
|
||||
onNoteReady(note);
|
||||
} catch (err) {
|
||||
console.error('Failed to create daily note:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Previous day"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleCreateDailyNote}
|
||||
disabled={loading}
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
{loading ? 'Creating…' : `Daily Note — ${displayDate}`}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => navigate(1)}
|
||||
aria-label="Next day"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Code,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Undo,
|
||||
Redo,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NoteEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Start writing... Use [[Note Title]] to link to other notes',
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange(e.getHTML());
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlur?.();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
label="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
label="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
label="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
label="Code"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
label="Bullet list"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
label="Ordered list"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
label="Blockquote"
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
label="Undo"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
label="Redo"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Editor content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
disabled,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading graph...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
val: number;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface NoteGraphProps {
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
export function NoteGraph({ notes }: NoteGraphProps) {
|
||||
const [graphData, setGraphData] = useState<{
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}>({ nodes: [], links: [] });
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 300, height: 500 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchGraphData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (graphRef.current) {
|
||||
const { width, height } = graphRef.current.getBoundingClientRect();
|
||||
setDimensions({ width: Math.floor(width) || 300, height: Math.floor(height) || 500 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function fetchGraphData() {
|
||||
try {
|
||||
const response = await fetch('/api/notes/graph');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
const nodes: GraphNode[] = (data.nodes || []).map(
|
||||
(node: { id: string; title: string; domain: string; connectionCount?: number }) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
domain: node.domain,
|
||||
val: (node.connectionCount || 0) + 1,
|
||||
})
|
||||
);
|
||||
|
||||
const links: GraphLink[] = (data.edges || []).map(
|
||||
(edge: { source: string; target: string }) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
})
|
||||
);
|
||||
|
||||
setGraphData({ nodes, links });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch graph data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No graph data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={graphRef} className="h-[500px] w-full">
|
||||
<ForceGraph2D
|
||||
graphData={graphData}
|
||||
nodeLabel="title"
|
||||
nodeAutoColorBy="domain"
|
||||
nodeRelSize={6}
|
||||
linkDirectionalArrowLength={6}
|
||||
linkDirectionalArrowRelPos={0.99}
|
||||
onNodeClick={(node: Record<string, unknown>) => {
|
||||
console.log('Clicked node:', node);
|
||||
}}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useRealtime, type RealtimeEvent } from '@/hooks/use-realtime';
|
||||
|
||||
type EventCallback = (event: RealtimeEvent) => void;
|
||||
|
||||
interface RealtimeContextType {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
reconnect: () => void;
|
||||
disconnect: () => void;
|
||||
subscribe: (
|
||||
collections: string[],
|
||||
callback: EventCallback
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
const RealtimeContext = createContext<RealtimeContextType | null>(null);
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const subscribersRef = useRef<Map<string, Set<EventCallback>>>(new Map());
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const handleEvent = useCallback((event: RealtimeEvent) => {
|
||||
const collection = event.collection || '*';
|
||||
const collectionSubs = subscribersRef.current.get(collection);
|
||||
const globalSubs = subscribersRef.current.get('*');
|
||||
|
||||
if (collectionSubs) {
|
||||
collectionSubs.forEach((cb) => cb(event));
|
||||
}
|
||||
if (globalSubs) {
|
||||
globalSubs.forEach((cb) => cb(event));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { connected, error, reconnect, disconnect } = useRealtime({
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
const subscribe = useCallback(
|
||||
(collections: string[], callback: EventCallback) => {
|
||||
for (const collection of collections) {
|
||||
if (!subscribersRef.current.has(collection)) {
|
||||
subscribersRef.current.set(collection, new Set());
|
||||
}
|
||||
subscribersRef.current.get(collection)!.add(callback);
|
||||
}
|
||||
// Also register as a global subscriber
|
||||
if (!subscribersRef.current.has('*')) {
|
||||
subscribersRef.current.set('*', new Set());
|
||||
}
|
||||
subscribersRef.current.get('*')!.add(callback);
|
||||
|
||||
forceRender((n) => n + 1);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
for (const collection of collections) {
|
||||
subscribersRef.current.get(collection)?.delete(callback);
|
||||
}
|
||||
subscribersRef.current.get('*')?.delete(callback);
|
||||
forceRender((n) => n + 1);
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<RealtimeContext.Provider
|
||||
value={{ connected, error, reconnect, disconnect, subscribe }}
|
||||
>
|
||||
{children}
|
||||
</RealtimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRealtimeContext() {
|
||||
const context = useContext(RealtimeContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useRealtimeContext must be used within a RealtimeProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Code,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Undo,
|
||||
Redo,
|
||||
Heading1,
|
||||
Heading2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ReportEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
disabled,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportEditor({ content, onChange, onBlur }: ReportEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Start writing your report...',
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange(e.getHTML());
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlur?.();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
label="Heading 1"
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
label="Heading 2"
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
label="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
label="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
label="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
label="Code"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
label="Bullet list"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
label="Ordered list"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
label="Blockquote"
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
label="Undo"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
label="Redo"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Editor content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, Target, TrendingUp, Clock, FileBarChart } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom';
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const templates: Template[] = [
|
||||
{
|
||||
id: 'weekly',
|
||||
name: 'Weekly Summary',
|
||||
description: 'Review your tasks, habits, and time from the past week',
|
||||
type: 'weekly',
|
||||
icon: Calendar,
|
||||
content: `
|
||||
<h1>Weekly Summary</h1>
|
||||
<h2>Overview</h2>
|
||||
<p>Week of [DATE_RANGE]</p>
|
||||
|
||||
<h2>Tasks Completed</h2>
|
||||
<ul>
|
||||
<li>[TASK_LIST]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Habits Tracked</h2>
|
||||
<ul>
|
||||
<li>[HABIT_SUMMARY]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Time Logged</h2>
|
||||
<p>Total: [TIME_TOTAL]</p>
|
||||
|
||||
<h2>Reflections</h2>
|
||||
<p>What went well this week?</p>
|
||||
<p>What could be improved?</p>
|
||||
<p>Goals for next week:</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'monthly',
|
||||
name: 'Monthly Review',
|
||||
description: 'Comprehensive review of the past month',
|
||||
type: 'monthly',
|
||||
icon: Calendar,
|
||||
content: `
|
||||
<h1>Monthly Review</h1>
|
||||
<h2>Month of [DATE_RANGE]</h2>
|
||||
|
||||
<h2>Key Achievements</h2>
|
||||
<ul>
|
||||
<li>[ACHIEVEMENTS]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Project Progress</h2>
|
||||
<ul>
|
||||
<li>[PROJECT_SUMMARY]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Habit Consistency</h2>
|
||||
<p>[HABIT_ANALYSIS]</p>
|
||||
|
||||
<h2>Time Distribution</h2>
|
||||
<p>[TIME_BREAKDOWN]</p>
|
||||
|
||||
<h2>Lessons Learned</h2>
|
||||
<p>What worked well?</p>
|
||||
<p>What didn't work?</p>
|
||||
|
||||
<h2>Next Month's Focus</h2>
|
||||
<p>Priorities:</p>
|
||||
<p>Goals:</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
name: 'Project Health',
|
||||
description: 'Status and progress report for a specific project',
|
||||
type: 'project',
|
||||
icon: Target,
|
||||
content: `
|
||||
<h1>Project Health Report</h1>
|
||||
<h2>[PROJECT_NAME]</h2>
|
||||
|
||||
<h2>Status Overview</h2>
|
||||
<p>Progress: [PROGRESS]%</p>
|
||||
<p>Tasks: [COMPLETED] / [TOTAL]</p>
|
||||
<p>Due: [DUE_DATE]</p>
|
||||
|
||||
<h2>Milestones</h2>
|
||||
<ul>
|
||||
<li>[MILESTONE_LIST]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Recent Activity</h2>
|
||||
<ul>
|
||||
<li>[RECENT_TASKS]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Risks & Blockers</h2>
|
||||
<p>[RISKS]</p>
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
<ul>
|
||||
<li>[NEXT_STEPS]</li>
|
||||
</ul>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'habit',
|
||||
name: 'Habit Analysis',
|
||||
description: 'Deep dive into habit performance and trends',
|
||||
type: 'habit',
|
||||
icon: TrendingUp,
|
||||
content: `
|
||||
<h1>Habit Analysis</h1>
|
||||
<h2>Period: [DATE_RANGE]</h2>
|
||||
|
||||
<h2>Overall Performance</h2>
|
||||
<p>Completion rate: [COMPLETION_RATE]%</p>
|
||||
<p>Active streaks: [STREAK_COUNT]</p>
|
||||
|
||||
<h2>Top Performing Habits</h2>
|
||||
<ol>
|
||||
<li>[TOP_HABITS]</li>
|
||||
</ol>
|
||||
|
||||
<h2>Habits Needing Attention</h2>
|
||||
<ul>
|
||||
<li>[AT_RISK_HABITS]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Trends & Patterns</h2>
|
||||
<p>[TREND_ANALYSIS]</p>
|
||||
|
||||
<h2>Recommendations</h2>
|
||||
<ul>
|
||||
<li>[RECOMMENDATIONS]</li>
|
||||
</ul>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'time',
|
||||
name: 'Time Audit',
|
||||
description: 'Breakdown of where your time went',
|
||||
type: 'custom',
|
||||
icon: Clock,
|
||||
content: `
|
||||
<h1>Time Audit</h1>
|
||||
<h2>Period: [DATE_RANGE]</h2>
|
||||
|
||||
<h2>Total Time Tracked</h2>
|
||||
<p>[TOTAL_TIME]</p>
|
||||
|
||||
<h2>By Domain</h2>
|
||||
<ul>
|
||||
<li>[DOMAIN_BREAKDOWN]</li>
|
||||
</ul>
|
||||
|
||||
<h2>By Project</h2>
|
||||
<ul>
|
||||
<li>[PROJECT_BREAKDOWN]</li>
|
||||
</ul>
|
||||
|
||||
<h2>By Category</h2>
|
||||
<ul>
|
||||
<li>[CATEGORY_BREAKDOWN]</li>
|
||||
</ul>
|
||||
|
||||
<h2>Insights</h2>
|
||||
<p>Where did most time go?</p>
|
||||
<p>Was time aligned with priorities?</p>
|
||||
<p>Adjustments for next period:</p>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
interface ReportTemplatesProps {
|
||||
onSelect: (template: Template) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ReportTemplates({ onSelect, onCancel }: ReportTemplatesProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Choose a Template</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Start with a template or create from scratch
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" role="list">
|
||||
{templates.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => onSelect(template)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(template);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-label={`Use template: ${template.name}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<template.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{template.name}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Custom template */}
|
||||
<Card
|
||||
className="cursor-pointer border-dashed transition-shadow hover:shadow-md"
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
id: 'custom',
|
||||
name: 'Custom Report',
|
||||
description: 'Start with a blank report',
|
||||
type: 'custom',
|
||||
icon: FileBarChart,
|
||||
content: '<h1>Custom Report</h1><p>Start writing...</p>',
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect({
|
||||
id: 'custom',
|
||||
name: 'Custom Report',
|
||||
description: 'Start with a blank report',
|
||||
type: 'custom',
|
||||
icon: FileBarChart,
|
||||
content: '<h1>Custom Report</h1><p>Start writing...</p>',
|
||||
});
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-label="Start with a blank custom report"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted p-2">
|
||||
<FileBarChart className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">Custom Report</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Start with a blank report</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Copy, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
api_key: string;
|
||||
permission_tier: string;
|
||||
status: 'active' | 'disabled';
|
||||
}
|
||||
|
||||
export function SettingsAgents() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newAgentName, setNewAgentName] = useState('');
|
||||
const [newAgentTier, setNewAgentTier] = useState('read_only');
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
}, []);
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
const response = await fetch('/api/agents');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAgents(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAgent() {
|
||||
if (!newAgentName.trim()) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newAgentName,
|
||||
permission_tier: newAgentTier,
|
||||
status: 'active',
|
||||
}),
|
||||
});
|
||||
setNewAgentName('');
|
||||
setCreateDialogOpen(false);
|
||||
fetchAgents();
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAgent(id: string) {
|
||||
if (!confirm('Are you sure? This will revoke the agent\'s access.')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/agents/${id}`, { method: 'DELETE' });
|
||||
fetchAgents();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete agent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiKey(apiKey: string) {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Agents & Permissions</CardTitle>
|
||||
<CardDescription>Manage AI agents and their access levels.</CardDescription>
|
||||
</div>
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New agent
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">Name</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
placeholder="e.g., Hermes, Claude"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-tier">Permission tier</Label>
|
||||
<Select value={newAgentTier} onValueChange={setNewAgentTier}>
|
||||
<SelectTrigger id="agent-tier">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full_access">Full Access</SelectItem>
|
||||
<SelectItem value="read_only">Read Only</SelectItem>
|
||||
<SelectItem value="content_creator">Content Creator</SelectItem>
|
||||
<SelectItem value="task_manager">Task Manager</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={createAgent} className="w-full">
|
||||
Create agent
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading agents...
|
||||
</p>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No agents configured
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<div key={agent.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{agent.name}</h3>
|
||||
<Badge variant={agent.status === 'active' ? 'default' : 'secondary'}>
|
||||
{agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{agent.permission_tier.replace('_', ' ')}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-2 py-1 text-xs">
|
||||
{agent.api_key.slice(0, 8)}...
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyApiKey(agent.api_key)}
|
||||
aria-label={`Copy API key for ${agent.name}`}
|
||||
>
|
||||
<Copy className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteAgent(agent.id)}
|
||||
aria-label={`Delete agent: ${agent.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useThemeStore } from '@/lib/stores/use-theme-store';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ACCENT_COLORS, FONTS, DENSITIES, THEME_MODES } from '@/lib/theme';
|
||||
|
||||
export function SettingsAppearance() {
|
||||
const { mode, accent, font, density, reducedMotion, setMode, setAccent, setFont, setDensity, setReducedMotion } = useThemeStore();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>Customize how Project E looks and feels.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme mode */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Theme</Label>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as 'light' | 'dark' | 'system')}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEME_MODES.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Accent color */}
|
||||
<div className="space-y-2">
|
||||
<Label>Accent color</Label>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Accent color">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => setAccent(color.value)}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-all',
|
||||
accent === color.value ? 'border-foreground scale-110' : 'border-transparent'
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
aria-label={`${color.name} accent color`}
|
||||
role="radio"
|
||||
aria-checked={accent === color.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-font">Font</Label>
|
||||
<Select value={font} onValueChange={setFont}>
|
||||
<SelectTrigger id="settings-font">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONTS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Density */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-density">Density</Label>
|
||||
<Select value={density} onValueChange={(v) => setDensity(v as 'compact' | 'comfortable' | 'spacious')}>
|
||||
<SelectTrigger id="settings-density">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DENSITIES.map((d) => (
|
||||
<SelectItem key={d.value} value={d.value}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Reduced motion */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Reduced motion</Label>
|
||||
<p className="text-sm text-muted-foreground">Minimize animations and transitions</p>
|
||||
</div>
|
||||
<Switch checked={reducedMotion} onCheckedChange={setReducedMotion} aria-label="Reduced motion" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface Domain {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export function SettingsDomains() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [newDomainName, setNewDomainName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDomains();
|
||||
}, []);
|
||||
|
||||
async function fetchDomains() {
|
||||
try {
|
||||
const response = await fetch('/api/domains?sort=sort_order');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDomains(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addDomain() {
|
||||
if (!newDomainName.trim()) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newDomainName,
|
||||
color: '#3b82f6',
|
||||
icon: '📁',
|
||||
sort_order: domains.length,
|
||||
}),
|
||||
});
|
||||
setNewDomainName('');
|
||||
fetchDomains();
|
||||
} catch (error) {
|
||||
console.error('Failed to add domain:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDomain(id: string) {
|
||||
if (!confirm('Are you sure? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/domains/${id}`, { method: 'DELETE' });
|
||||
fetchDomains();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete domain:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Domains</CardTitle>
|
||||
<CardDescription>Manage your workspace domains (e.g., Personal, Work, OTS).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Existing domains */}
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading domains...</p>
|
||||
) : (
|
||||
domains.map((domain) => (
|
||||
<div key={domain.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 w-4 rounded"
|
||||
style={{ backgroundColor: domain.color }}
|
||||
/>
|
||||
<span className="font-medium">{domain.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteDomain(domain.id)}
|
||||
aria-label={`Delete domain: ${domain.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new domain */}
|
||||
<div className="flex gap-2">
|
||||
<label htmlFor="new-domain-name" className="sr-only">
|
||||
New domain name
|
||||
</label>
|
||||
<Input
|
||||
id="new-domain-name"
|
||||
placeholder="New domain name"
|
||||
value={newDomainName}
|
||||
onChange={(e) => setNewDomainName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||
/>
|
||||
<Button onClick={addDomain}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Download, Upload, Check, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollectionInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ImportResultItem {
|
||||
collection: string;
|
||||
imported: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
failed: number;
|
||||
results: ImportResultItem[];
|
||||
}
|
||||
|
||||
const DEFAULT_COLLECTIONS: CollectionInfo[] = [
|
||||
{ name: 'tasks', label: 'Tasks' },
|
||||
{ name: 'habits', label: 'Habits' },
|
||||
{ name: 'projects', label: 'Projects' },
|
||||
{ name: 'notes', label: 'Notes' },
|
||||
{ name: 'reports', label: 'Reports' },
|
||||
{ name: 'milestones', label: 'Milestones' },
|
||||
{ name: 'domains', label: 'Domains' },
|
||||
{ name: 'tags', label: 'Tags' },
|
||||
{ name: 'agents', label: 'Agents' },
|
||||
{ name: 'webhooks', label: 'Webhooks' },
|
||||
];
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────
|
||||
|
||||
export function SettingsImportExport() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [selectedCollections, setSelectedCollections] = useState<string[]>(
|
||||
DEFAULT_COLLECTIONS.map((c) => c.name)
|
||||
);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [confirmImport, setConfirmImport] = useState(false);
|
||||
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
setExportProgress(0);
|
||||
|
||||
try {
|
||||
// Simulate progress while fetching
|
||||
const progressInterval = setInterval(() => {
|
||||
setExportProgress((prev) => Math.min(prev + 10, 90));
|
||||
}, 200);
|
||||
|
||||
const response = await fetch('/api/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ collections: selectedCollections }),
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setExportProgress(100);
|
||||
|
||||
// Download as JSON
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `project-e-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Export completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to export:', error);
|
||||
toast.error('Failed to export data');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
setExportProgress(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = '';
|
||||
|
||||
if (!file.name.endsWith('.json')) {
|
||||
toast.error('Please select a JSON file');
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingImportFile(file);
|
||||
setImportResult(null);
|
||||
setConfirmImport(true);
|
||||
}
|
||||
|
||||
async function executeImport() {
|
||||
if (!pendingImportFile) return;
|
||||
|
||||
setConfirmImport(false);
|
||||
setImporting(true);
|
||||
setImportProgress(0);
|
||||
|
||||
try {
|
||||
const text = await pendingImportFile.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!data.version) {
|
||||
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setImportProgress((prev) => Math.min(prev + 5, 90));
|
||||
}, 300);
|
||||
|
||||
const response = await fetch('/api/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.error?.message || 'Import failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const result: ImportResult = await response.json();
|
||||
setImportProgress(100);
|
||||
setImportResult(result);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Import complete: ${result.imported} records imported`);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Import finished with errors: ${result.imported} imported, ${result.failed} failed`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to import:', error);
|
||||
toast.error('Failed to parse import file. Please check the format.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
setPendingImportFile(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collection toggle ─────────────────────────────────────────────────────
|
||||
|
||||
function toggleCollection(name: string) {
|
||||
setSelectedCollections((prev) =>
|
||||
prev.includes(name) ? prev.filter((c) => c !== name) : [...prev, name]
|
||||
);
|
||||
}
|
||||
|
||||
function toggleAllCollections() {
|
||||
if (selectedCollections.length === DEFAULT_COLLECTIONS.length) {
|
||||
setSelectedCollections([]);
|
||||
} else {
|
||||
setSelectedCollections(DEFAULT_COLLECTIONS.map((c) => c.name));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import & Export</CardTitle>
|
||||
<CardDescription>Backup your data or restore from a previous export.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* ── Export Section ─────────────────────────────────────────────────── */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold">Export data</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Download your data as a JSON file. Choose which collections to include.
|
||||
</p>
|
||||
|
||||
{/* Collection selection */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={selectedCollections.length === DEFAULT_COLLECTIONS.length}
|
||||
onCheckedChange={toggleAllCollections}
|
||||
/>
|
||||
<Label htmlFor="select-all" className="text-sm font-medium">
|
||||
Select all
|
||||
</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
|
||||
{DEFAULT_COLLECTIONS.map((collection) => (
|
||||
<div key={collection.name} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`export-${collection.name}`}
|
||||
checked={selectedCollections.includes(collection.name)}
|
||||
onCheckedChange={() => toggleCollection(collection.name)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`export-${collection.name}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{collection.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{exporting && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Progress value={exportProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">Exporting... {exportProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || selectedCollections.length === 0}
|
||||
className="mt-4"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{exporting ? 'Exporting...' : 'Export to JSON'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Import Section ─────────────────────────────────────────────────── */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold">Import data</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Restore from a previously exported JSON file. All existing data will be supplemented.
|
||||
</p>
|
||||
|
||||
{/* Progress */}
|
||||
{importing && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Progress value={importProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">Importing... {importProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import results */}
|
||||
{importResult && (
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{importResult.success ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{importResult.imported} imported, {importResult.failed} failed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{importResult.results.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{importResult.results.map((r) => (
|
||||
<div key={r.collection} className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize text-muted-foreground">{r.collection}</span>
|
||||
<span>
|
||||
{r.imported} ok
|
||||
{r.failed > 0 && (
|
||||
<span className="text-destructive">, {r.failed} failed</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult.results.some((r) => r.errors.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-destructive">Errors:</p>
|
||||
<div className="mt-1 max-h-32 overflow-auto" role="list" aria-label="Import errors">
|
||||
{importResult.results
|
||||
.flatMap((r) => r.errors)
|
||||
.slice(0, 10)
|
||||
.map((error, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="mt-4 inline-block">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={importing}
|
||||
/>
|
||||
<Button variant="outline" disabled={importing} asChild>
|
||||
<span>
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{importing ? 'Importing...' : 'Import from JSON'}
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
|
||||
<AlertDialog open={confirmImport} onOpenChange={setConfirmImport}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm import</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will import data from the selected file. Existing records will not be
|
||||
overwritten, but new records will be created for each item in the file. Are you sure
|
||||
you want to proceed?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingImportFile(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={executeImport}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useKeyboardShortcutsStore } from '@/lib/stores/use-keyboard-shortcuts-store';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export function SettingsShortcuts() {
|
||||
const { enabled, shortcuts, setEnabled, resetShortcuts } = useKeyboardShortcutsStore();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Keyboard Shortcuts</CardTitle>
|
||||
<CardDescription>Customize keyboard shortcuts for quick navigation and actions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Enable/disable all */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Enable keyboard shortcuts</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Turn off all keyboard shortcuts globally
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} aria-label="Enable keyboard shortcuts" />
|
||||
</div>
|
||||
|
||||
{/* Shortcuts list */}
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{shortcut.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{shortcut.action}</p>
|
||||
</div>
|
||||
<kbd className="rounded border bg-muted px-2 py-1 text-sm font-mono">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<Button variant="outline" onClick={resetShortcuts}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Send, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Webhook {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
active: boolean;
|
||||
secret?: string;
|
||||
domain?: string;
|
||||
retry_count: number;
|
||||
last_triggered_at?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
interface WebhookDelivery {
|
||||
id: string;
|
||||
webhook_id: string;
|
||||
event_type: string;
|
||||
payload: Record<string, unknown>;
|
||||
success: boolean;
|
||||
response_status: number;
|
||||
response_body: string;
|
||||
attempts: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
const AVAILABLE_EVENTS = [
|
||||
'*',
|
||||
'task.completed',
|
||||
'habit.completed',
|
||||
'habit.streak_broken',
|
||||
'milestone.reached',
|
||||
'project.status_changed',
|
||||
'report.generated',
|
||||
'agent_task.completed',
|
||||
];
|
||||
|
||||
// ── Webhook Delivery History ───────────────────────────────────────────────
|
||||
|
||||
function WebhookDeliveryHistory({ webhookId }: { webhookId?: string }) {
|
||||
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retryingId, setRetryingId] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const fetchDeliveries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ perPage: '50', sort: '-created' });
|
||||
if (webhookId) params.set('webhook_id', webhookId);
|
||||
|
||||
const response = await fetch(`/api/webhook-deliveries?${params}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDeliveries(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch deliveries:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [webhookId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeliveries();
|
||||
}, [fetchDeliveries]);
|
||||
|
||||
async function handleRetry(deliveryId: string) {
|
||||
setRetryingId(deliveryId);
|
||||
try {
|
||||
const response = await fetch(`/api/webhook-deliveries/${deliveryId}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Retry queued');
|
||||
fetchDeliveries();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error?.message || 'Failed to queue retry');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to queue retry');
|
||||
} finally {
|
||||
setRetryingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Loading delivery history...</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveries.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No deliveries yet. Send a test event or trigger an event to see delivery history.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
<div className="space-y-2">
|
||||
{deliveries.map((delivery) => (
|
||||
<div key={delivery.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setExpandedId(expandedId === delivery.id ? null : delivery.id)}
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-primary"
|
||||
aria-expanded={expandedId === delivery.id}
|
||||
aria-label={`${expandedId === delivery.id ? 'Collapse' : 'Expand'} details for ${delivery.event_type}`}
|
||||
>
|
||||
{expandedId === delivery.id ? (
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{delivery.event_type}
|
||||
</button>
|
||||
<Badge variant={delivery.success ? 'default' : 'destructive'}>
|
||||
{delivery.success ? 'Success' : 'Failed'}
|
||||
</Badge>
|
||||
{delivery.response_status > 0 && (
|
||||
<Badge variant="outline">{delivery.response_status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{delivery.attempts} attempt{delivery.attempts !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{!delivery.success && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRetry(delivery.id)}
|
||||
disabled={retryingId === delivery.id}
|
||||
>
|
||||
{retryingId === delivery.id ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedId === delivery.id && (
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Timestamp</Label>
|
||||
<p className="text-sm">{new Date(delivery.created).toLocaleString()}</p>
|
||||
</div>
|
||||
{delivery.response_body && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Response</Label>
|
||||
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
{delivery.response_body}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Payload</Label>
|
||||
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
{JSON.stringify(delivery.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────
|
||||
|
||||
export function SettingsWebhooks() {
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newWebhookName, setNewWebhookName] = useState('');
|
||||
const [newWebhookUrl, setNewWebhookUrl] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('webhooks');
|
||||
|
||||
const fetchWebhooks = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/webhooks');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setWebhooks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch webhooks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWebhooks();
|
||||
}, [fetchWebhooks]);
|
||||
|
||||
async function addWebhook() {
|
||||
if (!newWebhookName.trim() || !newWebhookUrl.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/webhooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newWebhookName,
|
||||
url: newWebhookUrl,
|
||||
events: ['*'],
|
||||
active: true,
|
||||
domain: 'default',
|
||||
retry_count: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Webhook created');
|
||||
setNewWebhookName('');
|
||||
setNewWebhookUrl('');
|
||||
setCreateDialogOpen(false);
|
||||
fetchWebhooks();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error?.message || 'Failed to create webhook');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create webhook');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWebhook(webhook: Webhook) {
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${webhook.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: !webhook.active }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(webhook.active ? 'Webhook disabled' : 'Webhook enabled');
|
||||
fetchWebhooks();
|
||||
} else {
|
||||
toast.error('Failed to update webhook');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update webhook');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWebhook(id: string) {
|
||||
try {
|
||||
await fetch(`/api/webhooks/${id}`, { method: 'DELETE' });
|
||||
toast.success('Webhook deleted');
|
||||
setDeleteConfirmId(null);
|
||||
fetchWebhooks();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete webhook');
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhook(id: string) {
|
||||
setTestingId(id);
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(`Test delivery succeeded (${data.status})`);
|
||||
} else {
|
||||
toast.error(`Test delivery failed: ${data.response || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to send test event');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Webhooks</CardTitle>
|
||||
<CardDescription>
|
||||
Configure outbound webhooks for event notifications.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
||||
<TabsTrigger value="deliveries">Delivery History</TabsTrigger>
|
||||
</TabsList>
|
||||
{activeTab === 'webhooks' && (
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New webhook
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-name">Name</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
value={newWebhookName}
|
||||
onChange={(e) => setNewWebhookName(e.target.value)}
|
||||
placeholder="e.g., Slack notifications"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url">URL</Label>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
value={newWebhookUrl}
|
||||
onChange={(e) => setNewWebhookUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addWebhook} className="w-full">
|
||||
Create webhook
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="webhooks" className="mt-0">
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Loading webhooks...</p>
|
||||
) : webhooks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No webhooks configured. Click "New webhook" to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{webhooks.map((webhook) => (
|
||||
<div key={webhook.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{webhook.name}</h3>
|
||||
<Badge variant={webhook.active ? 'default' : 'secondary'}>
|
||||
{webhook.active ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-muted-foreground">{webhook.url}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{webhook.events.slice(0, 4).map((event) => (
|
||||
<Badge key={event} variant="outline" className="text-xs">
|
||||
{event}
|
||||
</Badge>
|
||||
))}
|
||||
{webhook.events.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{webhook.events.length - 4} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={webhook.active}
|
||||
onCheckedChange={() => toggleWebhook(webhook)}
|
||||
aria-label={`Toggle ${webhook.name}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => testWebhook(webhook.id)}
|
||||
disabled={testingId === webhook.id || !webhook.active}
|
||||
aria-label={`Send test event to ${webhook.name}`}
|
||||
>
|
||||
{testingId === webhook.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirmId(webhook.id)}
|
||||
aria-label={`Delete webhook: ${webhook.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deliveries" className="mt-0">
|
||||
<WebhookDeliveryHistory />
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete webhook?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this webhook and all its delivery history. This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteConfirmId && deleteWebhook(deleteConfirmId)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useKeyboardShortcutsStore } from '@/lib/stores/use-keyboard-shortcuts-store';
|
||||
|
||||
export function ShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { shortcuts } = useKeyboardShortcutsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen((prev) => !prev);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Press <kbd className="rounded border bg-muted px-1">?</kbd> to toggle this help
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto">
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">Navigation</h2>
|
||||
<div className="space-y-1">
|
||||
{shortcuts
|
||||
.filter((s) => s.action.startsWith('navigate_'))
|
||||
.map((shortcut) => (
|
||||
<div key={shortcut.key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{shortcut.description}</span>
|
||||
<kbd className="rounded border bg-muted px-2 py-0.5 text-xs font-mono">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">Actions</h2>
|
||||
<div className="space-y-1">
|
||||
{shortcuts
|
||||
.filter((s) => !s.action.startsWith('navigate_'))
|
||||
.map((shortcut) => (
|
||||
<div key={shortcut.key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{shortcut.description}</span>
|
||||
<kbd className="rounded border bg-muted px-2 py-0.5 text-xs font-mono">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSidebarStore } from '@/lib/stores/use-sidebar-store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Flame,
|
||||
FolderKanban,
|
||||
NotebookPen,
|
||||
FileBarChart,
|
||||
CalendarDays,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/tasks', label: 'Tasks', icon: ListTodo },
|
||||
{ href: '/habits', label: 'Habits', icon: Flame },
|
||||
{ href: '/projects', label: 'Projects', icon: FolderKanban },
|
||||
{ href: '/notes', label: 'Notes', icon: NotebookPen },
|
||||
{ href: '/reports', label: 'Reports', icon: FileBarChart },
|
||||
{ href: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||
{ href: '/analytics', label: 'Analytics', icon: BarChart3 },
|
||||
];
|
||||
|
||||
const workspaceItems = [
|
||||
{ href: '/agents', label: 'Agent Activity', icon: Bot },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { collapsed, toggle } = useSidebarStore();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col border-r bg-card transition-all duration-200',
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center justify-between px-4">
|
||||
{!collapsed && (
|
||||
<Link href="/dashboard" className="flex items-center gap-2 font-semibold">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
||||
E
|
||||
</span>
|
||||
Project E
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Primary">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return link;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Workspace">
|
||||
{!collapsed && (
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Workspace
|
||||
</p>
|
||||
)}
|
||||
{workspaceItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return link;
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
domain: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function TaskDetailPanel({
|
||||
task,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate,
|
||||
}: TaskDetailPanelProps) {
|
||||
const [title, setTitle] = useState(task.title);
|
||||
const [description, setDescription] = useState(task.description || '');
|
||||
const [status, setStatus] = useState(task.status);
|
||||
const [priority, setPriority] = useState(task.priority);
|
||||
const [domain, setDomain] = useState(task.domain);
|
||||
const [dueDate, setDueDate] = useState(task.due_date || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
domain,
|
||||
due_date: dueDate || null,
|
||||
}),
|
||||
});
|
||||
onUpdate();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
onUpdate();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-[500px] sm:w-[600px] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Task Details</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-status">Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as Task['status'])}
|
||||
>
|
||||
<SelectTrigger id="task-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-priority">Priority</Label>
|
||||
<Select
|
||||
value={priority}
|
||||
onValueChange={(v) => setPriority(v as Task['priority'])}
|
||||
>
|
||||
<SelectTrigger id="task-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-domain">Domain</Label>
|
||||
<Select value={domain} onValueChange={setDomain}>
|
||||
<SelectTrigger id="task-domain">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="personal">Personal</SelectItem>
|
||||
<SelectItem value="work">Work</SelectItem>
|
||||
<SelectItem value="ots">OTS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-due-date">Due Date</Label>
|
||||
<Input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
className="ml-auto"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, MoreHorizontal } from 'lucide-react';
|
||||
import { TaskDetailPanel } from './task-detail-panel';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
domain: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-slate-500' },
|
||||
{ id: 'in_progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
];
|
||||
|
||||
function DraggableTask({
|
||||
task,
|
||||
onClick,
|
||||
}: {
|
||||
task: Task;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`cursor-grab active:cursor-grabbing ${
|
||||
isDragging ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<Card className="mb-2 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="flex-1 text-left text-sm font-medium hover:underline"
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
aria-label={`More options for ${task.title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{task.domain}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DroppableColumn({
|
||||
id,
|
||||
title,
|
||||
color,
|
||||
tasks,
|
||||
onTaskClick,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({tasks.length})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
role="list"
|
||||
aria-label={`${title} tasks (${tasks.length} items)`}
|
||||
className={`flex-1 rounded-lg border-2 border-dashed p-3 min-h-[400px] transition-colors ${
|
||||
isOver ? 'border-primary bg-primary/5' : 'border-muted'
|
||||
}`}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<DraggableTask
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => onTaskClick(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksKanbanView() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/tasks?sort=-created');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const task = active.data.current?.task as Task;
|
||||
const newStatus = over.id as Task['status'];
|
||||
|
||||
if (task.status !== newStatus) {
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const task = event.active.data.current?.task as Task;
|
||||
setActiveTask(task);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading tasks...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{columns.map((column) => (
|
||||
<DroppableColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
tasks={tasks.filter((t) => t.status === column.id)}
|
||||
onTaskClick={setSelectedTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<Card className="rotate-3 shadow-xl">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium">{activeTask.title}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
open={!!selectedTask}
|
||||
onOpenChange={(open) => !open && setSelectedTask(null)}
|
||||
onUpdate={fetchTasks}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Calendar, MoreHorizontal } from 'lucide-react';
|
||||
import { TaskDetailPanel } from './task-detail-panel';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
domain: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export function TasksListView() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/tasks?sort=-created');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTaskComplete(task: Task) {
|
||||
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-muted-foreground">Loading tasks...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={task.status === 'done'}
|
||||
onCheckedChange={() => toggleTaskComplete(task)}
|
||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => setSelectedTask(task)}
|
||||
className={`text-left font-medium hover:underline ${
|
||||
task.status === 'done'
|
||||
? 'line-through text-muted-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{task.domain}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
aria-label={`More options for ${task.title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
open={!!selectedTask}
|
||||
onOpenChange={(open) => !open && setSelectedTask(null)}
|
||||
onUpdate={fetchTasks}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useThemeStore } from '@/lib/stores/use-theme-store';
|
||||
import { hexToHSL } from '@/lib/theme';
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { mode, accent, density, reducedMotion } = useThemeStore();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply dark/light mode
|
||||
if (mode === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
root.classList.toggle('dark', e.matches);
|
||||
};
|
||||
handler(mediaQuery);
|
||||
mediaQuery.addEventListener(
|
||||
'change',
|
||||
handler as (e: MediaQueryListEvent) => void
|
||||
);
|
||||
return () =>
|
||||
mediaQuery.removeEventListener(
|
||||
'change',
|
||||
handler as (e: MediaQueryListEvent) => void
|
||||
);
|
||||
} else {
|
||||
root.classList.toggle('dark', mode === 'dark');
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply accent color as CSS variable (convert hex to HSL for consistency)
|
||||
const hsl = hexToHSL(accent);
|
||||
root.style.setProperty('--primary', `${hsl.h} ${hsl.s}% ${hsl.l}%`);
|
||||
|
||||
// Apply density
|
||||
root.setAttribute('data-density', density);
|
||||
|
||||
// Apply reduced motion
|
||||
if (reducedMotion) {
|
||||
root.setAttribute('data-reduced-motion', 'true');
|
||||
} else {
|
||||
root.removeAttribute('data-reduced-motion');
|
||||
}
|
||||
}, [accent, density, reducedMotion]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Search, Bell, Plus, Menu } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSidebarStore } from '@/lib/stores/use-sidebar-store';
|
||||
import { CommandPalette } from '@/components/command-palette';
|
||||
|
||||
export function TopBar() {
|
||||
const { setMobileOpen } = useSidebarStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6" role="banner">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Search / Command trigger */}
|
||||
<div className="flex-1 md:max-w-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-muted-foreground"
|
||||
onClick={() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
}}
|
||||
aria-label="Open search (Cmd+K)"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Search or jump to...
|
||||
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
}}
|
||||
aria-label="Quick add"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
|
||||
Quick add
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ComponentRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ComponentRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CommandDialog = ({ children, label, className, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className={cn('overflow-hidden p-0 shadow-lg', className)}>
|
||||
{label && <DialogTitle className="sr-only">{label}</DialogTitle>}
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ComponentRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-64 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-outline outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('flex h-10 items-center space-x-1 rounded-md border border-border bg-background p-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(({ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayName = 'MenubarShortcut';
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ComponentRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative z-10 flex max-w-max flex-1 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ComponentRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('group flex flex-1 list-none items-center justify-center space-x-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ComponentRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn('absolute left-0 top-full flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ComponentRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ComponentRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuenow={value ?? undefined}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ComponentRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ComponentRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('flex items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-3',
|
||||
sm: 'h-9 px-2.5',
|
||||
lg: 'h-11 px-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ComponentRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useWebVitals } from '@/hooks/use-web-vitals';
|
||||
|
||||
/**
|
||||
* Thin client component that hooks Web Vitals tracking into the layout.
|
||||
* Kept separate so the layout itself stays a server component.
|
||||
*/
|
||||
export function WebVitalsTracker() {
|
||||
useWebVitals();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Component, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
widgetName?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class WidgetErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(
|
||||
`Widget error (${this.props.widgetName || 'unknown'}):`,
|
||||
error,
|
||||
errorInfo
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{this.props.widgetName || 'Widget'} failed to load
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user