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:
2026-07-16 06:19:58 -04:00
parent ec14645a4b
commit 8f55626e03
286 changed files with 31992 additions and 9245 deletions
@@ -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
/>
);
}
+205
View File
@@ -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&apos;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>
);
}
+97
View File
@@ -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&apos;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>
);
}
+163
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+94
View File
@@ -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 &quot;New webhook&quot; 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>
);
}
+81
View File
@@ -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>
);
}
+161
View File
@@ -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}
/>
)}
</>
);
}
+53
View File
@@ -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}</>;
}
+67
View File
@@ -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 />
</>
);
}
+57
View File
@@ -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 };
+114
View File
@@ -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,
};
+8
View File
@@ -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 };
+47
View File
@@ -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 };
+33
View File
@@ -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 };
+56
View File
@@ -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 };
+78
View File
@@ -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 };
+28
View File
@@ -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 };
+150
View File
@@ -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,
};
+186
View File
@@ -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,
};
+101
View File
@@ -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,
};
+187
View File
@@ -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,
};
+29
View File
@@ -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 };
+22
View File
@@ -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 };
+24
View File
@@ -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 };
+215
View File
@@ -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,
};
+122
View File
@@ -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,
};
+31
View File
@@ -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 };
+29
View File
@@ -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 };
+40
View File
@@ -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 };
+46
View File
@@ -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 };
+153
View File
@@ -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,
};
+26
View File
@@ -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 };
+137
View File
@@ -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,
};
+25
View File
@@ -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 };
+27
View File
@@ -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 };
+29
View File
@@ -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 };
+120
View File
@@ -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,
};
+55
View File
@@ -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 };
+22
View File
@@ -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 };
+58
View File
@@ -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 };
+44
View File
@@ -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 };
+30
View File
@@ -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;
}
}