God Mode Valhalla: Initial Standalone Commit
This commit is contained in:
61
src/components/admin/SystemStatus.tsx
Normal file
61
src/components/admin/SystemStatus.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
type SystemMetric = {
|
||||
label: string;
|
||||
status: 'active' | 'standby' | 'online' | 'connected' | 'ready' | 'error';
|
||||
color: string;
|
||||
};
|
||||
|
||||
export default function SystemStatus() {
|
||||
const [metrics, setMetrics] = useState<SystemMetric[]>([
|
||||
{ label: 'Intelligence Station', status: 'active', color: 'bg-green-500' },
|
||||
{ label: 'Production Station', status: 'active', color: 'bg-green-500' },
|
||||
{ label: 'WordPress Ignition', status: 'standby', color: 'bg-yellow-500' },
|
||||
{ label: 'Core API', status: 'online', color: 'bg-blue-500' },
|
||||
{ label: 'Directus DB', status: 'connected', color: 'bg-emerald-500' },
|
||||
{ label: 'WP Connection', status: 'ready', color: 'bg-green-500' }
|
||||
]);
|
||||
|
||||
// In a real scenario, we would poll an API here.
|
||||
// For now, we simulate the "Live" feeling or check basic connectivity.
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
// We can check Directus health via SDK in future
|
||||
// For now, we trust the static state or toggle visually to show life
|
||||
};
|
||||
checkHealth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-slate-900 border border-slate-700 shadow-xl w-full">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Sub-Station Status
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{metrics.map((m, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between group">
|
||||
<span className="text-sm text-slate-300 font-medium group-hover:text-white transition-colors">{m.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded text-white ${getStatusColor(m.status)}`}>
|
||||
{m.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-green-600';
|
||||
case 'standby': return 'bg-yellow-600';
|
||||
case 'online': return 'bg-blue-600';
|
||||
case 'connected': return 'bg-emerald-600';
|
||||
case 'ready': return 'bg-green-600';
|
||||
case 'error': return 'bg-red-600';
|
||||
default: return 'bg-gray-600';
|
||||
}
|
||||
}
|
||||
155
src/components/admin/SystemStatusBar.tsx
Normal file
155
src/components/admin/SystemStatusBar.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface SystemStatus {
|
||||
coreApi: 'online' | 'offline' | 'checking';
|
||||
database: 'connected' | 'disconnected' | 'checking';
|
||||
wpConnection: 'ready' | 'error' | 'checking';
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
time: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export default function SystemStatusBar() {
|
||||
const [status, setStatus] = useState<SystemStatus>({
|
||||
coreApi: 'checking',
|
||||
database: 'checking',
|
||||
wpConnection: 'checking'
|
||||
});
|
||||
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const addLog = (message: string, type: LogEntry['type']) => {
|
||||
const newLog: LogEntry = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
message,
|
||||
type
|
||||
};
|
||||
setLogs(prev => [newLog, ...prev].slice(0, 50));
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
// We check OUR OWN backend API route which can then proxy/check other services or just confirm this server is up.
|
||||
// This avoids CORS issues and ensures the frontend server is actually serving API routes correctly.
|
||||
const response = await fetch('/api/system/health');
|
||||
|
||||
if (response.ok) {
|
||||
setStatus({
|
||||
coreApi: 'online',
|
||||
database: 'connected',
|
||||
wpConnection: 'ready'
|
||||
});
|
||||
addLog('System check passed', 'success');
|
||||
} else {
|
||||
throw new Error(`Health check failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Status check failed:', error);
|
||||
setStatus({
|
||||
coreApi: 'offline',
|
||||
database: 'disconnected',
|
||||
wpConnection: 'error'
|
||||
});
|
||||
addLog(`System check failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (state: string) => {
|
||||
switch (state) {
|
||||
case 'online':
|
||||
case 'connected':
|
||||
case 'ready':
|
||||
return 'text-green-400';
|
||||
case 'offline':
|
||||
case 'disconnected':
|
||||
case 'error':
|
||||
return 'text-red-400';
|
||||
case 'checking':
|
||||
return 'text-yellow-400';
|
||||
default:
|
||||
return 'text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getLogColor = (type: LogEntry['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'text-green-400';
|
||||
case 'error':
|
||||
return 'text-red-400';
|
||||
case 'warning':
|
||||
return 'text-yellow-400';
|
||||
default:
|
||||
return 'text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-titanium border-t border-edge-normal shadow-xl">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="spark-label text-white">API & Logistics</h3>
|
||||
|
||||
<div className="flex items-center gap-6 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-silver">Core API</span>
|
||||
<span className={getStatusColor(status.coreApi)}>
|
||||
{status.coreApi.charAt(0).toUpperCase() + status.coreApi.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-silver">Database (Directus)</span>
|
||||
<span className={getStatusColor(status.database)}>
|
||||
{status.database.charAt(0).toUpperCase() + status.database.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-silver">WP Connection</span>
|
||||
<span className={getStatusColor(status.wpConnection)}>
|
||||
{status.wpConnection.charAt(0).toUpperCase() + status.wpConnection.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="spark-btn-ghost text-sm"
|
||||
>
|
||||
{showLogs ? 'Hide' : 'Show'} Processing Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="border-t border-edge-subtle bg-void">
|
||||
<div className="container mx-auto px-4 py-3 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-sm text-silver/50 italic">No recent activity</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm font-mono">
|
||||
<span className="text-silver/50 shrink-0">[{log.time}]</span>
|
||||
<span className={getLogColor(log.type)}>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/admin/posts/PostEditor.tsx
Normal file
47
src/components/admin/posts/PostEditor.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function PostEditor({ id }: { id: string }) {
|
||||
const [post, setPost] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const client = getDirectusClient();
|
||||
try {
|
||||
const data = await client.request(readItem('posts', id));
|
||||
setPost(data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!post) return <div>Post not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="bg-slate-800 border-slate-700 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Post Title</Label>
|
||||
<Input value={post.title} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={post.slug} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content (Markdown/HTML)</Label>
|
||||
<textarea className="w-full bg-slate-900 border-slate-700 rounded p-3 min-h-[300px]" value={post.content || ''}></textarea>
|
||||
</div>
|
||||
<Button className="mt-4">Save Changes</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/admin/posts/PostList.tsx
Normal file
68
src/components/admin/posts/PostList.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; // Need to implement Table? Or use grid.
|
||||
// Assume Table isn't fully ready or use Grid for now to be safe.
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Posts as Post } from '@/lib/schemas';
|
||||
|
||||
export default function PostList() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const data = await client.request(readItems('posts', { fields: ['*', 'site.name', 'author.name'], limit: 50 }));
|
||||
setPosts(data as unknown as Post[]);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-slate-400">
|
||||
<thead className="bg-slate-900/50 text-slate-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Title</th>
|
||||
<th className="px-6 py-3">Site</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{posts.map(post => (
|
||||
<tr key={post.id} className="hover:bg-slate-700/50 cursor-pointer transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-slate-200">{post.title}</div>
|
||||
<div className="text-xs text-slate-500">{post.slug}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{/* @ts-ignore */}
|
||||
{post.site?.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={post.status === 'published' ? 'default' : 'secondary'}>
|
||||
{post.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{posts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-6 py-12 text-center text-slate-500">
|
||||
No posts found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/components/admin/sites/NavigationManager.tsx
Normal file
141
src/components/admin/sites/NavigationManager.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Plus, Trash2, Save, GripVertical } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
interface NavigationManagerProps {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export default function NavigationManager({ siteId }: NavigationManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newItem, setNewItem] = useState({ label: '', url: '' });
|
||||
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ['navigation', siteId],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(readItems('navigation', {
|
||||
filter: { site: { _eq: siteId } },
|
||||
sort: ['sort']
|
||||
}));
|
||||
return res as unknown as NavItem[];
|
||||
}
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// @ts-ignore
|
||||
await client.request(createItem('navigation', {
|
||||
...newItem,
|
||||
site: siteId,
|
||||
sort: items.length + 1
|
||||
}));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
|
||||
setNewItem({ label: '', url: '' });
|
||||
toast.success('Menu item added');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
// @ts-ignore
|
||||
await client.request(deleteItem('navigation', id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
|
||||
toast.success('Menu item deleted');
|
||||
}
|
||||
});
|
||||
|
||||
const updateSortMutation = useMutation({
|
||||
mutationFn: async ({ id, sort }: { id: string, sort: number }) => {
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('navigation', id, { sort }));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-lg p-4">
|
||||
<h3 className="text-white font-medium mb-4">Add Menu Item</h3>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="Label (e.g. Home)"
|
||||
value={newItem.label}
|
||||
onChange={e => setNewItem({ ...newItem, label: e.target.value })}
|
||||
className="bg-zinc-950 border-zinc-800"
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL (e.g. /home)"
|
||||
value={newItem.url}
|
||||
onChange={e => setNewItem({ ...newItem, url: e.target.value })}
|
||||
className="bg-zinc-950 border-zinc-800"
|
||||
/>
|
||||
<Button onClick={() => createMutation.mutate()} disabled={!newItem.label} className="bg-blue-600 hover:bg-blue-500 whitespace-nowrap">
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-950">
|
||||
<TableRow className="border-zinc-800 hover:bg-zinc-950">
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead className="text-zinc-400">Label</TableHead>
|
||||
<TableHead className="text-zinc-400">URL</TableHead>
|
||||
<TableHead className="text-zinc-400 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center text-zinc-500">
|
||||
No menu items. Add one above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id} className="border-zinc-800 hover:bg-zinc-900/50">
|
||||
<TableCell>
|
||||
<GripVertical className="h-4 w-4 text-zinc-600 cursor-grab" />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-white">
|
||||
{item.label}
|
||||
</TableCell>
|
||||
<TableCell className="text-zinc-400 font-mono text-xs">
|
||||
{item.url}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-600 hover:text-red-500" onClick={() => deleteMutation.mutate(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
src/components/admin/sites/PageEditor.tsx
Normal file
257
src/components/admin/sites/PageEditor.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { GripVertical, Plus, Trash2, LayoutTemplate, Type, Image as ImageIcon, Save, ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
interface PageBlock {
|
||||
id: string;
|
||||
block_type: 'hero' | 'content' | 'features' | 'cta';
|
||||
block_config: any;
|
||||
}
|
||||
|
||||
interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
permalink: string;
|
||||
status: string;
|
||||
blocks: PageBlock[];
|
||||
}
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export default function PageEditor({ pageId, onBack }: PageEditorProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [blocks, setBlocks] = useState<PageBlock[]>([]);
|
||||
const [pageMeta, setPageMeta] = useState<Partial<Page>>({});
|
||||
|
||||
const { isLoading } = useQuery({
|
||||
queryKey: ['page', pageId],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(readItem('pages', pageId));
|
||||
const page = res as unknown as Page;
|
||||
setBlocks(page.blocks || []);
|
||||
setPageMeta({ title: page.title, permalink: page.permalink, status: page.status });
|
||||
return page;
|
||||
},
|
||||
enabled: !!pageId
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('pages', pageId, {
|
||||
...pageMeta,
|
||||
blocks: blocks
|
||||
}));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['page', pageId] });
|
||||
toast.success('Page saved successfully');
|
||||
}
|
||||
});
|
||||
|
||||
const addBlock = (block_type: PageBlock['block_type']) => {
|
||||
const newBlock: PageBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
block_type,
|
||||
block_config: block_type === 'hero' ? { title: 'New Hero', subtitle: 'Subtitle here', bg: 'default' } :
|
||||
block_type === 'content' ? { content: '<p>Start writing...</p>' } :
|
||||
block_type === 'features' ? { items: [{ title: 'Feature 1', desc: 'Description' }] } :
|
||||
{ label: 'Click Me', url: '#' }
|
||||
};
|
||||
setBlocks([...blocks, newBlock]);
|
||||
};
|
||||
|
||||
const updateBlock = (id: string, config: any) => {
|
||||
setBlocks(blocks.map(b => b.id === id ? { ...b, block_config: { ...b.block_config, ...config } } : b));
|
||||
};
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
setBlocks(blocks.filter(b => b.id !== id));
|
||||
};
|
||||
|
||||
const moveBlock = (index: number, direction: 'up' | 'down') => {
|
||||
const newBlocks = [...blocks];
|
||||
if (direction === 'up' && index > 0) {
|
||||
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
|
||||
} else if (direction === 'down' && index < newBlocks.length - 1) {
|
||||
[newBlocks[index + 1], newBlocks[index]] = [newBlocks[index], newBlocks[index + 1]];
|
||||
}
|
||||
setBlocks(newBlocks);
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center text-zinc-500">Loading editor...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 text-white overflow-hidden">
|
||||
{/* Sidebar Controls */}
|
||||
<div className="w-80 border-r border-zinc-800 bg-zinc-900/50 flex flex-col">
|
||||
<div className="p-4 border-b border-zinc-800 flex items-center gap-2">
|
||||
{onBack && <Button variant="ghost" size="icon" onClick={onBack}><ArrowLeft className="h-4 w-4" /></Button>}
|
||||
<div>
|
||||
<h2 className="font-bold text-sm">Page Editor</h2>
|
||||
<Input
|
||||
value={pageMeta.title || ''}
|
||||
onChange={e => setPageMeta({ ...pageMeta, title: e.target.value })}
|
||||
className="h-7 text-xs bg-transparent border-0 px-0 focus-visible:ring-0 placeholder:text-zinc-600"
|
||||
placeholder="Page Title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div>
|
||||
<label className="text-xs uppercase font-bold text-zinc-500 mb-2 block">Add Blocks</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" className="justify-start border-zinc-800 hover:bg-zinc-800" onClick={() => addBlock('hero')}>
|
||||
<LayoutTemplate className="mr-2 h-4 w-4 text-purple-400" /> Hero
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start border-zinc-800 hover:bg-zinc-800" onClick={() => addBlock('content')}>
|
||||
<Type className="mr-2 h-4 w-4 text-blue-400" /> Content
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start border-zinc-800 hover:bg-zinc-800" onClick={() => addBlock('features')}>
|
||||
<ImageIcon className="mr-2 h-4 w-4 text-green-400" /> Features
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs uppercase font-bold text-zinc-500 mb-2 block">Page Settings</label>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-400">Permalink</label>
|
||||
<Input
|
||||
value={pageMeta.permalink || ''}
|
||||
onChange={e => setPageMeta({ ...pageMeta, permalink: e.target.value })}
|
||||
className="bg-zinc-950 border-zinc-800 h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-400">Status</label>
|
||||
<select
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-sm h-8"
|
||||
value={pageMeta.status || 'draft'}
|
||||
onChange={e => setPageMeta({ ...pageMeta, status: e.target.value })}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-zinc-800">
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-500" onClick={() => saveMutation.mutate()}>
|
||||
<Save className="mr-2 h-4 w-4" /> Save Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Canvas (Preview + Edit) */}
|
||||
<div className="flex-1 overflow-y-auto bg-zinc-950 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{blocks.map((block, index) => (
|
||||
<Card key={block.id} className="bg-zinc-900 border-zinc-800 relative group transition-all hover:border-zinc-700">
|
||||
{/* Block Actions */}
|
||||
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 bg-zinc-900 border border-zinc-800 rounded-md p-1 shadow-xl z-20">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => moveBlock(index, 'up')}><span className="sr-only">Up</span>↑</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => moveBlock(index, 'down')}><span className="sr-only">Down</span>↓</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={() => removeBlock(block.id)}><Trash2 className="h-3 w-3" /></Button>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-6">
|
||||
{/* Type Label */}
|
||||
<div className="absolute left-0 top-0 bg-zinc-800 text-zinc-500 text-[10px] uppercase font-bold px-2 py-1 rounded-br opacity-50">{block.block_type}</div>
|
||||
|
||||
{/* HERO EDITOR */}
|
||||
{block.block_type === 'hero' && (
|
||||
<div className="text-center space-y-4 py-8">
|
||||
<Input
|
||||
value={block.block_config.title}
|
||||
onChange={e => updateBlock(block.id, { title: e.target.value })}
|
||||
className="text-4xl font-bold bg-transparent border-0 text-center placeholder:text-zinc-700 h-auto focus-visible:ring-0 p-0"
|
||||
placeholder="Hero Headline"
|
||||
/>
|
||||
<Input
|
||||
value={block.block_config.subtitle}
|
||||
onChange={e => updateBlock(block.id, { subtitle: e.target.value })}
|
||||
className="text-xl text-zinc-400 bg-transparent border-0 text-center placeholder:text-zinc-700 h-auto focus-visible:ring-0 p-0"
|
||||
placeholder="Hero Subtitle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CONTENT EDITOR */}
|
||||
{block.block_type === 'content' && (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={block.block_config.content}
|
||||
onChange={e => updateBlock(block.id, { content: e.target.value })}
|
||||
className="min-h-[150px] bg-zinc-950 border-zinc-800 font-serif text-lg leading-relaxed text-zinc-300"
|
||||
placeholder="Write your HTML content or markdown here..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FEATURES EDITOR */}
|
||||
{block.block_type === 'features' && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(block.block_config.items || []).map((item: any, i: number) => (
|
||||
<div key={i} className="p-4 rounded bg-zinc-950 border border-zinc-800 space-y-2">
|
||||
<Input
|
||||
value={item.title}
|
||||
onChange={e => {
|
||||
const newItems = [...block.block_config.items];
|
||||
newItems[i].title = e.target.value;
|
||||
updateBlock(block.id, { items: newItems });
|
||||
}}
|
||||
className="font-bold bg-transparent border-0 p-0 h-auto focus-visible:ring-0"
|
||||
/>
|
||||
<Textarea
|
||||
value={item.desc}
|
||||
onChange={e => {
|
||||
const newItems = [...block.block_config.items];
|
||||
newItems[i].desc = e.target.value;
|
||||
updateBlock(block.id, { items: newItems });
|
||||
}}
|
||||
className="text-xs text-zinc-400 bg-transparent border-0 p-0 h-auto resize-none min-h-[40px] focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" className="h-full border-dashed border-zinc-800 text-zinc-600" onClick={() => {
|
||||
const newItems = [...(block.block_config.items || []), { title: 'New Feature', desc: 'Desc' }];
|
||||
updateBlock(block.id, { items: newItems });
|
||||
}}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{blocks.length === 0 && (
|
||||
<div className="h-64 flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-lg text-zinc-600">
|
||||
<LayoutTemplate className="h-12 w-12 mb-4 opacity-20" />
|
||||
<p>Page is empty.</p>
|
||||
<p className="text-sm">Use the sidebar to add blocks.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/admin/sites/SiteDashboard.tsx
Normal file
41
src/components/admin/sites/SiteDashboard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import SitePagesManager from './SitePagesManager';
|
||||
import NavigationManager from './NavigationManager';
|
||||
import ThemeSettings from './ThemeSettings';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
interface SiteDashboardProps {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export default function SiteDashboard({ siteId }: SiteDashboardProps) {
|
||||
return (
|
||||
<Tabs defaultValue="pages" className="space-y-6">
|
||||
<TabsList className="bg-zinc-900 border border-zinc-800">
|
||||
<TabsTrigger value="pages" className="data-[state=active]:bg-zinc-800">Pages</TabsTrigger>
|
||||
<TabsTrigger value="navigation" className="data-[state=active]:bg-zinc-800">Navigation</TabsTrigger>
|
||||
<TabsTrigger value="appearance" className="data-[state=active]:bg-zinc-800">Appearance</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="data-[state=active]:bg-zinc-800">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pages" className="space-y-4">
|
||||
<SitePagesManager siteId={siteId} siteDomain="example.com" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="navigation">
|
||||
<NavigationManager siteId={siteId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance">
|
||||
<ThemeSettings siteId={siteId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<div className="text-zinc-500 p-8 border border-dashed border-zinc-800 rounded-lg text-center">
|
||||
Advanced site settings coming soon in Milestone 5.
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
221
src/components/admin/sites/SiteEditor.tsx
Normal file
221
src/components/admin/sites/SiteEditor.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Sites as Site } from '@/lib/schemas';
|
||||
import DomainSetupGuide from '@/components/admin/DomainSetupGuide';
|
||||
|
||||
interface SiteEditorProps {
|
||||
id: string; // Astro passes string params
|
||||
}
|
||||
|
||||
export default function SiteEditor({ id }: SiteEditorProps) {
|
||||
const [site, setSite] = useState<Site | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Feature Flags State (mapped to settings)
|
||||
const [features, setFeatures] = useState({
|
||||
maintenance_mode: false,
|
||||
seo_indexing: true,
|
||||
https_enforced: true,
|
||||
analytics_enabled: false,
|
||||
blog_enabled: true,
|
||||
leads_capture: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const result = await client.request(readItem('sites', id));
|
||||
setSite(result as unknown as Site);
|
||||
|
||||
// Merge settings into defaults
|
||||
if (result.settings) {
|
||||
setFeatures(prev => ({ ...prev, ...(result.settings as Record<string, any>) }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!site) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('sites', id, {
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
status: site.status,
|
||||
settings: features
|
||||
}));
|
||||
// Show toast?
|
||||
alert("Site Settings Saved!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Error saving site.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!site) return <div>Site not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Header / Meta */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle>General Information</CardTitle>
|
||||
<CardDescription>Basic site identity and connectivity.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Site Name</Label>
|
||||
<Input
|
||||
value={site.name}
|
||||
onChange={(e) => setSite({ ...site, name: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700"
|
||||
placeholder="My Awesome Site"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Internal identifier for this site</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Domain</Label>
|
||||
<Input
|
||||
value={site.url || ''}
|
||||
onChange={(e) => setSite({ ...site, url: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 font-mono text-blue-400"
|
||||
placeholder="example.com"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Your custom domain (without https://)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature Toggles (CheckBox Options) */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Feature Configuration</CardTitle>
|
||||
<CardDescription>Enable or disable specific modules and behaviors for this site.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Maintenance Mode */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Maintenance Mode</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Show a "Coming Soon" page to all visitors.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.maintenance_mode}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, maintenance_mode: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Indexing */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Search Indexing</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Allow Google/Bing to index this site.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.seo_indexing}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, seo_indexing: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTPS Enforced */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Enforce HTTPS</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Redirect all HTTP traffic to HTTPS.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.https_enforced}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, https_enforced: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Analytics */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Analytics</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Inject GTM/GA4 scripts.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.analytics_enabled}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, analytics_enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blog */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Blog System</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Enable generated posts and archive pages.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.blog_enabled}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, blog_enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Leads */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Lead Capture</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Process form submissions and webhooks.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.leads_capture}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, leads_capture: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Domain Setup Guide */}
|
||||
<DomainSetupGuide siteDomain={site.url} />
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-green-600 hover:bg-green-700">
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/admin/sites/SiteList.tsx
Normal file
87
src/components/admin/sites/SiteList.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Sites as Site } from '@/lib/schemas';
|
||||
|
||||
export default function SiteList() {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const s = await client.request(readItems('sites'));
|
||||
setSites(s as unknown as Site[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-slate-400">Loading sites...</div>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sites.map(site => (
|
||||
<Card key={site.id} className="bg-slate-800 border-slate-700 hover:border-slate-600 transition-all cursor-pointer group" onClick={() => window.location.href = `/admin/sites/${site.id}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-200">
|
||||
{site.name}
|
||||
</CardTitle>
|
||||
<Badge className={site.status === 'active' ? 'bg-green-600' : 'bg-slate-600'}>
|
||||
{site.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white mb-2">{site.url || 'No URL set'}</div>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
{site.url ? '🟢 Site configured' : '⚠️ Set up site URL'}
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = `/admin/sites/${site.id}`;
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (site.url) {
|
||||
window.open(`https://${site.url || 'No URL'}`, '_blank');
|
||||
} else {
|
||||
alert('Set up a domain first in site settings');
|
||||
}
|
||||
}}
|
||||
>
|
||||
👁️ Preview
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Empty State / Add New Placeholder */}
|
||||
{sites.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 bg-slate-800/50 rounded-xl border border-dashed border-slate-700">
|
||||
<p className="text-slate-400">No sites found.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/admin/sites/SitePagesManager.tsx
Normal file
166
src/components/admin/sites/SitePagesManager.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItems, createItem, deleteItem } from '@/lib/directus/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { FileText, Plus, Trash2, Edit, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
permalink: string;
|
||||
status: string;
|
||||
date_updated: string;
|
||||
}
|
||||
|
||||
interface SitePagesManagerProps {
|
||||
siteId: string;
|
||||
siteDomain: string;
|
||||
}
|
||||
|
||||
export default function SitePagesManager({ siteId, siteDomain }: SitePagesManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newPageTitle, setNewPageTitle] = useState('');
|
||||
|
||||
const { data: pages = [], isLoading } = useQuery({
|
||||
queryKey: ['pages', siteId],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(readItems('pages', {
|
||||
filter: { site: { _eq: siteId } },
|
||||
sort: ['permalink']
|
||||
}));
|
||||
return res as unknown as Page[];
|
||||
}
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(createItem('pages', {
|
||||
title: newPageTitle,
|
||||
site: siteId, // UUID usually
|
||||
permalink: `/${newPageTitle.toLowerCase().replace(/ /g, '-')}`,
|
||||
status: 'draft',
|
||||
blocks: []
|
||||
}));
|
||||
return res;
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages', siteId] });
|
||||
toast.success('Page created');
|
||||
setCreateOpen(false);
|
||||
setNewPageTitle('');
|
||||
// Redirect to editor
|
||||
window.location.href = `/admin/sites/editor/${data.id}`;
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error('Failed to create page: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
// @ts-ignore
|
||||
await client.request(deleteItem('pages', id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages', siteId] });
|
||||
toast.success('Page deleted');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<h3 className="text-lg font-medium text-white flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-blue-400" /> Pages
|
||||
</h3>
|
||||
<Button onClick={() => setCreateOpen(true)} className="bg-blue-600 hover:bg-blue-500">
|
||||
<Plus className="mr-2 h-4 w-4" /> New Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-950">
|
||||
<TableRow className="border-zinc-800 hover:bg-zinc-950">
|
||||
<TableHead className="text-zinc-400">Title</TableHead>
|
||||
<TableHead className="text-zinc-400">Permalink</TableHead>
|
||||
<TableHead className="text-zinc-400">Status</TableHead>
|
||||
<TableHead className="text-zinc-400 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pages.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center text-zinc-500">
|
||||
No pages yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pages.map((page) => (
|
||||
<TableRow key={page.id} className="border-zinc-800 hover:bg-zinc-900/50">
|
||||
<TableCell className="font-medium text-white">
|
||||
{page.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-zinc-400 font-mono text-xs">
|
||||
{page.permalink}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={page.status === 'published' ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-zinc-500/10 text-zinc-500'}>
|
||||
{page.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white" onClick={() => window.location.href = `/admin/sites/editor/${page.id}`}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-red-500" onClick={() => { if (confirm('Delete page?')) deleteMutation.mutate(page.id); }}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{page.status === 'published' && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-400 hover:text-blue-300" onClick={() => window.open(`https://${siteDomain}${page.permalink}`, '_blank')}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Page</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500 mb-2 block">Page Title</label>
|
||||
<Input
|
||||
value={newPageTitle}
|
||||
onChange={e => setNewPageTitle(e.target.value)}
|
||||
placeholder="e.g. About Us"
|
||||
className="bg-zinc-950 border-zinc-800"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => createMutation.mutate()} disabled={!newPageTitle} className="bg-blue-600 hover:bg-blue-500">Create & Edit</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/admin/sites/SitesManager.tsx
Normal file
178
src/components/admin/sites/SitesManager.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Globe, Plus, Settings, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'active' | 'inactive';
|
||||
settings?: any;
|
||||
}
|
||||
|
||||
export default function SitesManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingSite, setEditingSite] = useState<Partial<Site>>({});
|
||||
|
||||
// Fetch
|
||||
const { data: sites = [], isLoading } = useQuery({
|
||||
queryKey: ['sites'],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(readItems('sites', { limit: -1 }));
|
||||
return res as unknown as Site[];
|
||||
}
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (site: Partial<Site>) => {
|
||||
if (site.id) {
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('sites', site.id, site));
|
||||
} else {
|
||||
// @ts-ignore
|
||||
await client.request(createItem('sites', site));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
toast.success(editingSite.id ? 'Site updated' : 'Site created');
|
||||
setEditorOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
// @ts-ignore
|
||||
await client.request(deleteItem('sites', id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
toast.success('Site deleted');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center bg-zinc-900/50 p-6 rounded-lg border border-zinc-800 backdrop-blur-sm">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Your Sites</h2>
|
||||
<p className="text-zinc-400 text-sm">Manage your deployed web properties.</p>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-500" onClick={() => { setEditingSite({}); setEditorOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Site
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sites.map((site) => (
|
||||
<Card key={site.id} className="bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-zinc-200">
|
||||
{site.name}
|
||||
</CardTitle>
|
||||
<Badge variant={site.status === 'active' ? 'default' : 'secondary'} className={site.status === 'active' ? 'bg-green-500/10 text-green-500 hover:bg-green-500/20' : ''}>
|
||||
{site.status || 'inactive'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold truncate text-white tracking-tight">{site.url}</div>
|
||||
<p className="text-xs text-zinc-500 mt-1 flex items-center">
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
deployed via Launchpad
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t border-zinc-800 pt-4">
|
||||
<Button variant="ghost" size="sm" className="text-zinc-400 hover:text-white" onClick={() => window.open(`https://${site.url}`, '_blank')}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" /> Visit
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="bg-zinc-800 border-zinc-700 hover:bg-zinc-700 text-zinc-300" onClick={() => window.location.href = `/admin/sites/${site.id}`}>
|
||||
Manage Content
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="bg-purple-900/30 border-purple-700 hover:bg-purple-800/40 text-purple-300" onClick={() => window.open(`/preview/site/${site.id}`, '_blank')}>
|
||||
👁️ Preview
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white" onClick={() => { setEditingSite(site); setEditorOpen(true); }}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-red-500" onClick={() => { if (confirm('Delete site?')) deleteMutation.mutate(site.id); }}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{sites.length === 0 && (
|
||||
<div className="col-span-full h-64 flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-lg text-zinc-500">
|
||||
<Globe className="h-10 w-10 mb-4 opacity-20" />
|
||||
<p>No sites configured yet.</p>
|
||||
<Button variant="link" onClick={() => { setEditingSite({}); setEditorOpen(true); }}>Create your first site</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSite.id ? 'Edit Site' : 'New Site'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500">Site Name</label>
|
||||
<Input
|
||||
value={editingSite.name || ''}
|
||||
onChange={e => setEditingSite({ ...editingSite, name: e.target.value })}
|
||||
placeholder="My Awesome Blog"
|
||||
className="bg-zinc-950 border-zinc-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500">Domain</label>
|
||||
<div className="flex">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-zinc-800 bg-zinc-900 text-zinc-500 text-sm">https://</span>
|
||||
<Input
|
||||
value={editingSite.url || ''}
|
||||
onChange={e => setEditingSite({ ...editingSite, url: e.target.value })}
|
||||
placeholder="example.com"
|
||||
className="rounded-l-none bg-zinc-950 border-zinc-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500">Status</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1 text-sm shadow-sm transition-colors text-white"
|
||||
value={editingSite.status || 'active'}
|
||||
onChange={e => setEditingSite({ ...editingSite, status: e.target.value as any })}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setEditorOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => mutation.mutate(editingSite)} className="bg-blue-600 hover:bg-blue-500">Save Site</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/admin/sites/ThemeSettings.tsx
Normal file
116
src/components/admin/sites/ThemeSettings.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Save, Palette, Type } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
interface GlobalSettings {
|
||||
id?: string;
|
||||
site: string;
|
||||
primary_color: string;
|
||||
secondary_color: string;
|
||||
footer_text: string;
|
||||
}
|
||||
|
||||
interface ThemeSettingsProps {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export default function ThemeSettings({ siteId }: ThemeSettingsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useState<Partial<GlobalSettings>>({});
|
||||
|
||||
const { data: globalRecord, isLoading } = useQuery({
|
||||
queryKey: ['globals', siteId],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(readItems('globals', {
|
||||
filter: { site: { _eq: siteId } },
|
||||
limit: 1
|
||||
}));
|
||||
const record = res[0] as GlobalSettings;
|
||||
if (record) setSettings(record);
|
||||
return record;
|
||||
}
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (globalRecord?.id) {
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('globals', globalRecord.id, settings));
|
||||
} else {
|
||||
// @ts-ignore
|
||||
await client.request(createItem('globals', { ...settings, site: siteId }));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['globals', siteId] });
|
||||
toast.success('Theme settings saved');
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-zinc-500">Loading settings...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-white flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-purple-400" /> Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500">Primary Color</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-10 h-10 rounded border border-zinc-700" style={{ backgroundColor: settings.primary_color || '#000000' }}></div>
|
||||
<Input
|
||||
value={settings.primary_color || ''}
|
||||
onChange={e => setSettings({ ...settings, primary_color: e.target.value })}
|
||||
placeholder="#000000"
|
||||
className="bg-zinc-950 border-zinc-800 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500">Secondary Color</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-10 h-10 rounded border border-zinc-700" style={{ backgroundColor: settings.secondary_color || '#ffffff' }}></div>
|
||||
<Input
|
||||
value={settings.secondary_color || ''}
|
||||
onChange={e => setSettings({ ...settings, secondary_color: e.target.value })}
|
||||
placeholder="#ffffff"
|
||||
className="bg-zinc-950 border-zinc-800 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-zinc-800">
|
||||
<h3 className="text-lg font-medium text-white flex items-center gap-2">
|
||||
<Type className="h-5 w-5 text-blue-400" /> Typography & Text
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold text-zinc-500">Footer Text</label>
|
||||
<Textarea
|
||||
value={settings.footer_text || ''}
|
||||
onChange={e => setSettings({ ...settings, footer_text: e.target.value })}
|
||||
className="bg-zinc-950 border-zinc-800 min-h-[100px]"
|
||||
placeholder="© 2024 My Company. All rights reserved."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<Button onClick={() => saveMutation.mutate()} className="bg-blue-600 hover:bg-blue-500 w-full md:w-auto">
|
||||
<Save className="mr-2 h-4 w-4" /> Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/debug/DebugToolbar.tsx
Normal file
178
src/components/debug/DebugToolbar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { debugIsOpen, activeTab, logs, type LogEntry } from '../../stores/debugStore';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
|
||||
// Create a client for the devtools if one doesn't exist in context
|
||||
// (Ideally this component is inside the main QueryClientProvider, but we'll see)
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function DebugToolbar() {
|
||||
const isOpen = useStore(debugIsOpen);
|
||||
const currentTab = useStore(activeTab);
|
||||
const logEntries = useStore(logs);
|
||||
const [backendStatus, setBackendStatus] = useState<'checking' | 'online' | 'error'>('checking');
|
||||
const [latency, setLatency] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && currentTab === 'backend') {
|
||||
checkBackend();
|
||||
}
|
||||
}, [isOpen, currentTab]);
|
||||
|
||||
const checkBackend = async () => {
|
||||
setBackendStatus('checking');
|
||||
const start = performance.now();
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
await client.request(() => ({
|
||||
path: '/server/ping',
|
||||
method: 'GET'
|
||||
}));
|
||||
setLatency(Math.round(performance.now() - start));
|
||||
setBackendStatus('online');
|
||||
} catch (e) {
|
||||
setBackendStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => debugIsOpen.set(true)}
|
||||
className="fixed bottom-4 right-4 z-[9999] p-3 bg-black text-white rounded-full shadow-2xl hover:scale-110 transition-transform border border-gray-700"
|
||||
title="Open Debug Toolbar"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 h-[33vh] z-[9999] bg-black/95 text-white border-t border-gray-800 shadow-[0_-4px_20px_rgba(0,0,0,0.5)] flex flex-col font-mono text-sm backdrop-blur">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold text-yellow-500">⚡ Spark Debug</span>
|
||||
<div className="flex gap-1 bg-gray-800 rounded p-1">
|
||||
{(['console', 'backend', 'network'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => activeTab.set(tab)}
|
||||
className={`px-3 py-1 rounded text-xs uppercase font-medium transition-colors ${currentTab === tab
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => debugIsOpen.set(false)}
|
||||
className="p-1 hover:bg-gray-800 rounded"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
|
||||
{/* Console Tab */}
|
||||
{currentTab === 'console' && (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-1">
|
||||
{logEntries.length === 0 && (
|
||||
<div className="text-gray-500 text-center mt-10">No logs captured yet...</div>
|
||||
)}
|
||||
{logEntries.map((log) => (
|
||||
<div key={log.id} className="flex gap-2 font-mono text-xs border-b border-gray-800/50 pb-1">
|
||||
<span className="text-gray-500 shrink-0">[{log.timestamp}]</span>
|
||||
<span className={`shrink-0 w-12 font-bold uppercase ${log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' :
|
||||
'text-blue-400'
|
||||
}`}>
|
||||
{log.type}
|
||||
</span>
|
||||
<span className="text-gray-300 break-all">
|
||||
{log.messages.join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<button
|
||||
onClick={() => logs.set([])}
|
||||
className="px-2 py-1 bg-gray-800 text-xs rounded hover:bg-gray-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend Tab */}
|
||||
{currentTab === 'backend' && (
|
||||
<div className="h-full p-6 flex flex-col items-center justify-center gap-4">
|
||||
<div className={`text-4xl ${backendStatus === 'online' ? 'text-green-500' :
|
||||
backendStatus === 'error' ? 'text-red-500' :
|
||||
'text-yellow-500 animate-pulse'
|
||||
}`}>
|
||||
{backendStatus === 'online' ? '● Online' :
|
||||
backendStatus === 'error' ? '✖ Error' : '● Checking...'}
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400">
|
||||
Directus URL: <span className="text-white">{import.meta.env.PUBLIC_DIRECTUS_URL}</span>
|
||||
</p>
|
||||
{latency && (
|
||||
<p className="text-gray-400">
|
||||
Latency: <span className="text-white">{latency}ms</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={checkBackend}
|
||||
className="px-4 py-2 bg-gray-800 rounded hover:bg-gray-700 transition"
|
||||
>
|
||||
Re-check Connection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network / React Query Tab */}
|
||||
{currentTab === 'network' && (
|
||||
<div className="h-full w-full relative bg-gray-900">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-500">
|
||||
{/*
|
||||
React Query Devtools needs a QueryClientProvider context.
|
||||
In Astro, components are islands. If this island doesn't share context with the main app
|
||||
(which it likely won't if they are separate roots), we might see empty devtools.
|
||||
However, putting it here is the best attempt.
|
||||
*/}
|
||||
<div className="text-center">
|
||||
<p className="mb-2">React Query Devtools</p>
|
||||
<p className="text-xs">
|
||||
(If empty, data fetching might be happening Server-Side or in a different Context)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* We force mount devtools panel here if possible */}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={true} />
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/engine/BlockRenderer.tsx
Normal file
39
src/components/engine/BlockRenderer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import Hero from './blocks/Hero';
|
||||
import Content from './blocks/Content';
|
||||
import Features from './blocks/Features';
|
||||
|
||||
interface Block {
|
||||
id: string;
|
||||
block_type: string;
|
||||
block_config: any;
|
||||
}
|
||||
|
||||
interface BlockRendererProps {
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
export default function BlockRenderer({ blocks }: BlockRendererProps) {
|
||||
if (!blocks || !Array.isArray(blocks)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{blocks.map(block => {
|
||||
switch (block.block_type) {
|
||||
case 'hero':
|
||||
return <Hero key={block.id} {...block.block_config} />;
|
||||
case 'content':
|
||||
return <Content key={block.id} {...block.block_config} />;
|
||||
case 'features':
|
||||
return <Features key={block.id} {...block.block_config} />;
|
||||
case 'cta':
|
||||
// reuse Hero styled as CTA or simple banner
|
||||
return <Hero key={block.id} {...block.block_config} bg="dark" />;
|
||||
default:
|
||||
console.warn(`Unknown block type: ${block.block_type}`);
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/components/engine/blocks/Content.tsx
Normal file
13
src/components/engine/blocks/Content.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function Content({ content }: ContentProps) {
|
||||
return (
|
||||
<section className="py-12 px-8">
|
||||
<div className="prose prose-lg dark:prose-invert mx-auto" dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
src/components/engine/blocks/Features.tsx
Normal file
40
src/components/engine/blocks/Features.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface FeatureItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface FeaturesProps {
|
||||
items: FeatureItem[];
|
||||
layout?: 'grid' | 'list';
|
||||
}
|
||||
|
||||
export default function Features({ items, layout = 'grid' }: FeaturesProps) {
|
||||
return (
|
||||
<section className="py-16 px-8 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className={`grid gap-8 ${layout === 'list' ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-3'}`}>
|
||||
{items?.map((item, i) => (
|
||||
<Card key={i} className="border-0 shadow-lg bg-white dark:bg-zinc-900 dark:border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4 text-blue-600 dark:text-blue-400">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{item.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 leading-relaxed">
|
||||
{item.desc}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
src/components/engine/blocks/Hero.tsx
Normal file
38
src/components/engine/blocks/Hero.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface HeroProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
bg?: string;
|
||||
ctaLabel?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export default function Hero({ title, subtitle, bg, ctaLabel, ctaUrl }: HeroProps) {
|
||||
const bgClass = bg === 'dark' ? 'bg-zinc-900 text-white' :
|
||||
bg === 'image' ? 'bg-zinc-800 text-white' : // Placeholder for image logic
|
||||
'bg-white text-zinc-900';
|
||||
|
||||
return (
|
||||
<section className={`py-20 px-8 text-center ${bgClass}`}>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-xl md:text-2xl opacity-80 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{(ctaLabel && ctaUrl) && (
|
||||
<div className="pt-4">
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 rounded-full">
|
||||
<a href={ctaUrl}>{ctaLabel}</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
src/components/providers/CoreProviders.tsx
Normal file
21
src/components/providers/CoreProviders.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster as SonnerToaster } from 'sonner';
|
||||
import { queryClient } from '@/lib/react-query';
|
||||
|
||||
interface CoreProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CoreProvider = ({ children }: CoreProviderProps) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<SonnerToaster position="top-right" theme="system" richColors closeButton />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const GlobalToaster = () => {
|
||||
return <SonnerToaster position="top-right" theme="system" richColors closeButton />;
|
||||
}
|
||||
0
src/components/testing/ContentTester.tsx
Normal file
0
src/components/testing/ContentTester.tsx
Normal file
0
src/components/testing/DuplicateDetector.tsx
Normal file
0
src/components/testing/DuplicateDetector.tsx
Normal file
0
src/components/testing/GrammarCheck.tsx
Normal file
0
src/components/testing/GrammarCheck.tsx
Normal file
0
src/components/testing/LinkChecker.tsx
Normal file
0
src/components/testing/LinkChecker.tsx
Normal file
0
src/components/testing/SEOValidator.tsx
Normal file
0
src/components/testing/SEOValidator.tsx
Normal file
0
src/components/testing/SchemaValidator.tsx
Normal file
0
src/components/testing/SchemaValidator.tsx
Normal file
109
src/components/testing/TestRunner.tsx
Normal file
109
src/components/testing/TestRunner.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { CheckCircle2, AlertTriangle, XCircle, Search, FileText } from 'lucide-react';
|
||||
// We import the analysis functions directly since this is a client component in Astro/React
|
||||
import { analyzeSeo, analyzeReadability } from '@/lib/testing/seo';
|
||||
|
||||
const TestRunner = () => {
|
||||
const [content, setContent] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState<any>(null);
|
||||
|
||||
const runTests = () => {
|
||||
const seo = analyzeSeo(content, keyword);
|
||||
const read = analyzeReadability(content);
|
||||
|
||||
setResults({ seo, read });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-140px)]">
|
||||
|
||||
{/* Input Column */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="p-4 space-y-4 bg-card/50 backdrop-blur">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> Content Source
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Target Keyword"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
placeholder="Paste content here to analyze..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={runTests} className="w-full">
|
||||
<Search className="h-4 w-4 mr-2" /> Run Analysis
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results Column */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
{results ? (
|
||||
<>
|
||||
<Card className="p-6 bg-card/50 backdrop-blur space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<h3 className="font-semibold">SEO Score</h3>
|
||||
<span className={`font-bold ${results.seo.score >= 80 ? 'text-green-500' : 'text-yellow-500'}`}>
|
||||
{results.seo.score}/100
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={results.seo.score} className="h-2" />
|
||||
<div className="mt-4 space-y-2">
|
||||
{results.seo.issues.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-green-500 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4" /> No issues found!
|
||||
</div>
|
||||
)}
|
||||
{results.seo.issues.map((issue: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-yellow-500 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-border/50">
|
||||
<div className="flex justify-between mb-2">
|
||||
<h3 className="font-semibold">Readability</h3>
|
||||
<span className="text-muted-foreground text-sm">{results.read.feedback}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-background/50 rounded border border-border/50 text-center">
|
||||
<div className="text-2xl font-bold">{results.read.gradeLevel}</div>
|
||||
<div className="text-xs text-muted-foreground">Grade Level</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/50 rounded border border-border/50 text-center">
|
||||
<div className="text-2xl font-bold">{results.read.score}</div>
|
||||
<div className="text-xs text-muted-foreground">Flow Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground opacity-50 border-dashed">
|
||||
<Search className="h-12 w-12 mb-4" />
|
||||
<p>No results yet. Run analysis to see scores.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestRunner;
|
||||
29
src/components/ui/UnderConstruction.tsx
Normal file
29
src/components/ui/UnderConstruction.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Construction } from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface UnderConstructionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
eta?: string;
|
||||
}
|
||||
|
||||
const UnderConstruction = ({ title, description = "This module is currently being built.", eta = "Coming Soon" }: UnderConstructionProps) => {
|
||||
return (
|
||||
<Card className="border-dashed border-2 border-border/50 bg-card/20 backdrop-blur-sm h-[400px] flex flex-col items-center justify-center text-center p-8">
|
||||
<div className="p-4 rounded-full bg-primary/10 mb-6 animate-pulse">
|
||||
<Construction className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">{title}</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-6">
|
||||
{description}
|
||||
</p>
|
||||
<Badge variant="outline" className="px-4 py-1 border-primary/20 text-primary">
|
||||
{eta}
|
||||
</Badge>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnderConstruction;
|
||||
43
src/components/ui/alert-dialog.tsx
Normal file
43
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
|
||||
const AlertDialog = ({ open, onOpenChange, children }: any) => {
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative z-50">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogContent = ({ children, className }: any) => (
|
||||
<div className={`bg-slate-800 rounded-lg shadow-lg max-w-md w-full p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const AlertDialogHeader = ({ children }: any) => <div className="mb-4">{children}</div>
|
||||
const AlertDialogTitle = ({ children, className }: any) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>
|
||||
const AlertDialogDescription = ({ children, className }: any) => <p className={`text-sm text-slate-400 ${className}`}>{children}</p>
|
||||
const AlertDialogFooter = ({ children }: any) => <div className="mt-6 flex justify-end gap-2">{children}</div>
|
||||
const AlertDialogAction = ({ children, onClick, disabled, className }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} className={`px-4 py-2 rounded ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
const AlertDialogCancel = ({ children, disabled, className }: any) => (
|
||||
<button disabled={disabled} className={`px-4 py-2 rounded ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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-md 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 shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow 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 }
|
||||
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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 whitespace-nowrap rounded-md text-sm font-medium 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",
|
||||
{
|
||||
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-input 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
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal 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-lg 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<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
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 }
|
||||
20
src/components/ui/checkbox.tsx
Normal file
20
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Checkbox.displayName = "Checkbox"
|
||||
|
||||
export { Checkbox }
|
||||
31
src/components/ui/dialog.tsx
Normal file
31
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
|
||||
const Dialog = ({ open, onOpenChange, children }: any) => {
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative z-50 animate-in fade-in zoom-in-95 duration-200">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTrigger = ({ children, asChild, onClick, ...props }: any) => {
|
||||
// This is a simplified trigger that just renders children.
|
||||
// In a real implementation (Radix UI), this controls the dialog state.
|
||||
// For now, we rely on the parent controlling 'open' state.
|
||||
return <div onClick={onClick} {...props}>{children}</div>
|
||||
}
|
||||
|
||||
const DialogContent = ({ children, className }: any) => (
|
||||
<div className={`bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl max-w-lg w-full p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const DialogHeader = ({ children }: any) => <div className="mb-4 text-left">{children}</div>
|
||||
const DialogTitle = ({ children, className }: any) => <h2 className={`text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-zinc-400 ${className}`}>{children}</h2>
|
||||
const DialogDescription = ({ children, className }: any) => <p className={`text-sm text-zinc-400 ${className}`}>{children}</p>
|
||||
const DialogFooter = ({ children }: any) => <div className="mt-6 flex justify-end gap-2">{children}</div>
|
||||
|
||||
export { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle, DialogDescription, DialogFooter }
|
||||
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"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.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<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 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.ElementRef<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 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.ElementRef<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 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",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<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 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",
|
||||
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.ElementRef<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 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",
|
||||
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.ElementRef<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.ElementRef<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,
|
||||
}
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 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 }
|
||||
12
src/components/ui/label.tsx
Normal file
12
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label ref={ref} className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...props} />
|
||||
)
|
||||
)
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
14
src/components/ui/progress.tsx
Normal file
14
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
// Simplified Progress without radix for speed, or if radix is missing
|
||||
const Progress = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { value?: number }>(
|
||||
({ className, value, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} {...props}>
|
||||
<div className="h-full w-full flex-1 bg-primary transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
20
src/components/ui/select.tsx
Normal file
20
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
|
||||
const Select = ({ children, value, onValueChange }: any) => {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectTrigger = ({ children, className }: any) => <>{children}</>
|
||||
const SelectValue = ({ placeholder }: any) => <option value="">{placeholder}</option>
|
||||
const SelectContent = ({ children, className }: any) => <>{children}</>
|
||||
const SelectItem = ({ value, children }: any) => <option value={value}>{children}</option>
|
||||
|
||||
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }
|
||||
20
src/components/ui/slider.tsx
Normal file
20
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
type="range"
|
||||
className={cn(
|
||||
"w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Slider.displayName = "Slider"
|
||||
|
||||
export { Slider }
|
||||
22
src/components/ui/spinner.tsx
Normal file
22
src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, size = "default" }: { className?: string; size?: "sm" | "default" | "lg" }) {
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
default: "h-8 w-8",
|
||||
lg: "h-12 w-12"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"animate-spin rounded-full border-2 border-current border-t-transparent text-primary",
|
||||
sizeClasses[size]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
33
src/components/ui/switch.tsx
Normal file
33
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import * as React from "react"
|
||||
import type { Primitive } from "@radix-ui/react-primitive"
|
||||
// Simplified Switch to avoid Radix dependency issues if not installed, or use standard div toggle
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { checked?: boolean, onCheckedChange?: (checked: boolean) => void }>(
|
||||
({ className, checked, onCheckedChange, ...props }, ref) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
ref={ref}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
className={cn(
|
||||
"peer inline-flex h-[24px] w-[44px] 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 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
checked ? "bg-primary" : "bg-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
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",
|
||||
checked ? "translate-x-5 bg-white" : "translate-x-0 bg-slate-400"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
)
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export { Switch }
|
||||
119
src/components/ui/table.tsx
Normal file
119
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
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,
|
||||
}
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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.ElementRef<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.ElementRef<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.ElementRef<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 }
|
||||
19
src/components/ui/textarea.tsx
Normal file
19
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={`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 }
|
||||
Reference in New Issue
Block a user