initial design fix

This commit is contained in:
2026-04-22 21:26:59 -05:00
parent 874cbfb6a8
commit 0d44d2cd90
45 changed files with 3509 additions and 84 deletions
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CODEDUMP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "ai-tools-dashboard-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"lucide-react": "^0.364.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.2",
"vite": "^5.2.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+94
View File
@@ -0,0 +1,94 @@
import type { Project, Tool, Settings } from '../types';
const BASE = '/api';
const TOKEN_KEY = 'codedump_token';
function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
async function req<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (options.body && typeof options.body === 'string') headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401) {
// Token expired — clear storage and reload to login
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('codedump_user');
window.location.href = '/login';
throw new Error('Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Auth
export const pinLogin = (pin: string) =>
req<{ token: string; user: any }>('/auth/pin', { method: 'POST', body: JSON.stringify({ pin }) });
export const adminLogin = (username: string, password: string) =>
req<{ token: string; user: any }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
// Users (admin only)
export const getUsers = () => req<any[]>('/users');
export const createUser = (data: any) =>
req<any>('/users', { method: 'POST', body: JSON.stringify(data) });
export const updateUser = (id: string, data: any) =>
req<any>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteUser = (id: string) => req<void>(`/users/${id}`, { method: 'DELETE' });
// Projects
export const getProjects = () => req<Project[]>('/projects');
export const getProject = (id: string) => req<Project>(`/projects/${id}`);
export const createProject = (data: Partial<Project>) =>
req<Project>('/projects', { method: 'POST', body: JSON.stringify(data) });
export const updateProject = (id: string, data: Partial<Project>) =>
req<Project>(`/projects/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteProject = (id: string) => req<void>(`/projects/${id}`, { method: 'DELETE' });
// Tools
export const getTools = () => req<Tool[]>('/tools');
export const createTool = (data: Partial<Tool>) =>
req<Tool>('/tools', { method: 'POST', body: JSON.stringify(data) });
export const updateTool = (id: string, data: Partial<Tool>) =>
req<Tool>(`/tools/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteTool = (id: string) => req<void>(`/tools/${id}`, { method: 'DELETE' });
// Uploads
export const uploadDocument = (projectId: string, file: File) => {
const form = new FormData();
form.append('file', file);
const token = getToken();
return fetch(`${BASE}/uploads/projects/${projectId}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
}).then((r) => r.json());
};
export const deleteDocument = (id: string) => req<void>(`/uploads/documents/${id}`, { method: 'DELETE' });
export const getFileUrl = (filename: string) => `${BASE}/uploads/${filename}`;
// Settings
export const getSettings = () => req<Settings>('/settings');
export const updateSettings = (data: Partial<Settings>) =>
req<Settings>('/settings', { method: 'PUT', body: JSON.stringify(data) });
export const uploadLogo = (file: File) => {
const form = new FormData();
form.append('logo', file);
const token = getToken();
return fetch(`${BASE}/settings/logo`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
}).then((r) => r.json());
};
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
export default function Layout() {
return (
<div className="flex min-h-screen bg-base text-white">
<Sidebar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Wrench, Settings, Users, LogOut, Shield } from 'lucide-react';
import { useSettings } from '../../hooks/useSettings';
import { useAuth } from '../../hooks/useAuth';
const userLinks = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/tools', icon: Wrench, label: 'Tools' },
];
const adminLinks = [
{ to: '/settings', icon: Settings, label: 'Settings' },
{ to: '/admin/users', icon: Users, label: 'Users' },
];
export default function Sidebar() {
const { settings } = useSettings();
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
const isAdmin = user?.role === 'admin';
return (
<aside className="flex flex-col w-60 min-h-screen bg-surface border-r border-border shrink-0">
{/* Logo / Brand */}
<div className="flex items-center gap-3 px-5 py-5 border-b border-border">
{settings.logo_url ? (
<img src={settings.logo_url} alt="Logo" className="h-8 w-8 object-contain rounded" />
) : (
<div
className="h-8 w-8 rounded-lg flex items-center justify-center text-white text-sm font-bold shrink-0"
style={{ background: 'var(--accent)' }}
>
{settings.company_name?.charAt(0)?.toUpperCase() || 'C'}
</div>
)}
<div className="overflow-hidden">
<p className="text-white font-black text-sm leading-tight tracking-tight">CODEDUMP</p>
<p className="text-muted text-xs truncate">{settings.company_name}</p>
</div>
</div>
{/* Main nav */}
<nav className="flex-1 px-3 py-4 space-y-1">
{userLinks.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? 'text-white bg-[var(--accent-dim)] border border-[var(--accent)]/30'
: 'text-muted hover:text-white hover:bg-card'
}`
}
>
{({ isActive }) => (
<>
<Icon size={16} className="shrink-0" style={isActive ? { color: 'var(--accent)' } : {}} />
{label}
</>
)}
</NavLink>
))}
{/* Admin-only section */}
{isAdmin && (
<>
<div className="pt-3 pb-1 px-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted/50 flex items-center gap-1.5">
<Shield size={9} /> Admin
</p>
</div>
{adminLinks.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? 'text-white bg-[var(--accent-dim)] border border-[var(--accent)]/30'
: 'text-muted hover:text-white hover:bg-card'
}`
}
>
{({ isActive }) => (
<>
<Icon size={16} className="shrink-0" style={isActive ? { color: 'var(--accent)' } : {}} />
{label}
</>
)}
</NavLink>
))}
</>
)}
</nav>
{/* User info + logout */}
<div className="px-3 py-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2 rounded-lg">
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
style={{ background: isAdmin ? '#8b5cf6' : 'var(--accent)' }}
>
{user?.username?.charAt(0)?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white truncate">{user?.username}</p>
<p className="text-[10px] text-muted capitalize">{user?.role}</p>
</div>
<button
onClick={handleLogout}
title="Sign out"
className="p-1.5 rounded-lg hover:bg-white/5 text-muted hover:text-red-400 transition-colors"
>
<LogOut size={14} />
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,110 @@
import { useNavigate } from 'react-router-dom';
import { ExternalLink, FolderOpen, FileText, Percent } from 'lucide-react';
import type { Project } from '../../types';
import ProgressBar from '../ui/ProgressBar';
import Badge from '../ui/Badge';
interface Props {
project: Project;
}
export default function ProjectCard({ project }: Props) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(`/projects/${project.id}`)}
className="group relative bg-card border border-border rounded-xl p-5 cursor-pointer hover:border-[var(--accent)]/40 hover:shadow-lg hover:shadow-black/20 transition-all duration-200 animate-fade-in"
>
{/* Accent top bar */}
<div
className="absolute top-0 left-0 right-0 h-0.5 rounded-t-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: project.accent_color }}
/>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{project.is_new && <Badge variant="new">New</Badge>}
<Badge variant={project.status}>{project.status}</Badge>
</div>
<h3 className="font-semibold text-white text-base leading-tight truncate">{project.name}</h3>
<p className="text-xs text-muted mt-0.5">{project.category}</p>
</div>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: `${project.accent_color}22`, border: `1px solid ${project.accent_color}44` }}
>
<FolderOpen size={16} style={{ color: project.accent_color }} />
</div>
</div>
{/* Description */}
{project.description && (
<p className="text-sm text-muted/80 line-clamp-2 mb-4">{project.description}</p>
)}
{/* Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted flex items-center gap-1">
<Percent size={10} /> Completion
</span>
<span className="text-xs font-mono text-white">{project.completion}%</span>
</div>
<ProgressBar value={project.completion} color={project.accent_color} />
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted">
{project.doc_count !== undefined && (
<span className="flex items-center gap-1">
<FileText size={11} /> {project.doc_count} doc{project.doc_count !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{project.external_url && (
<a
href={project.external_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded hover:bg-white/10 text-muted hover:text-white transition-colors"
title="Open link"
>
<ExternalLink size={13} />
</a>
)}
{project.drive_url && (
<a
href={project.drive_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded hover:bg-white/10 text-muted hover:text-white transition-colors"
title="Google Drive"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<path d="M4.433 22l3.464-6h12l-3.464 6H4.433zM0 15L6 3.5 9.464 9.5 3.464 20.5 0 15zM14.536 9.5L11.072 3.5H23.072L19.608 9.5H14.536z" />
</svg>
</a>
)}
</div>
</div>
{/* Tags */}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border">
{project.tags.map((tag) => (
<span key={tag} className="text-[11px] px-2 py-0.5 rounded-md bg-white/5 text-muted">
{tag}
</span>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import type { Project } from '../../types';
import Button from '../ui/Button';
const ACCENT_PRESETS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f97316',
'#10b981', '#06b6d4', '#eab308', '#ef4444',
];
const CATEGORIES = ['General', 'AI/ML', 'Automation', 'Data', 'DevOps', 'Frontend', 'Backend', 'Research', 'Other'];
interface Props {
initial?: Partial<Project>;
onSubmit: (data: Partial<Project>) => Promise<void>;
onCancel: () => void;
}
export default function ProjectForm({ initial, onSubmit, onCancel }: Props) {
const [form, setForm] = useState({
name: initial?.name || '',
description: initial?.description || '',
category: initial?.category || 'General',
status: initial?.status || 'active',
completion: initial?.completion ?? 0,
external_url: initial?.external_url || '',
drive_url: initial?.drive_url || '',
tags: initial?.tags?.join(', ') || '',
accent_color: initial?.accent_color || '#6366f1',
is_new: initial?.is_new ?? true,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) { setError('Name is required'); return; }
setLoading(true);
setError('');
try {
await onSubmit({
...form,
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
completion: Number(form.completion),
external_url: form.external_url || null,
drive_url: form.drive_url || null,
});
} catch (err: any) {
setError(err.message || 'Something went wrong');
setLoading(false);
}
};
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Project Name *</label>
<input className={field} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="My AI Project" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Category</label>
<select className={field} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Status</label>
<select className={field} value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as any })}>
<option value="active">Active</option>
<option value="complete">Complete</option>
<option value="archived">Archived</option>
</select>
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Description</label>
<textarea className={`${field} resize-none`} rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Brief overview of the project..." />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Completion {form.completion}%</label>
<input type="range" min={0} max={100} value={form.completion}
onChange={(e) => setForm({ ...form, completion: Number(e.target.value) })}
className="w-full accent-[var(--accent)]"
/>
</div>
<div className="flex items-center gap-3 pt-4">
<input type="checkbox" id="is_new" checked={form.is_new}
onChange={(e) => setForm({ ...form, is_new: e.target.checked })}
className="accent-[var(--accent)] w-4 h-4"
/>
<label htmlFor="is_new" className="text-sm text-muted cursor-pointer">Mark as New</label>
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">External URL</label>
<input className={field} value={form.external_url} onChange={(e) => setForm({ ...form, external_url: e.target.value })} placeholder="https://..." type="url" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Google Drive URL</label>
<input className={field} value={form.drive_url} onChange={(e) => setForm({ ...form, drive_url: e.target.value })} placeholder="https://drive.google.com/..." type="url" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Tags (comma separated)</label>
<input className={field} value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="python, llm, automation" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-2 block">Accent Color</label>
<div className="flex items-center gap-2 flex-wrap">
{ACCENT_PRESETS.map((c) => (
<button key={c} type="button" onClick={() => setForm({ ...form, accent_color: c })}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: form.accent_color === c ? 'white' : 'transparent' }}
/>
))}
<input type="color" value={form.accent_color}
onChange={(e) => setForm({ ...form, accent_color: e.target.value })}
className="w-7 h-7 rounded-full cursor-pointer bg-transparent border-0 p-0"
title="Custom color"
/>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={loading} className="flex-1">
{initial?.id ? 'Save Changes' : 'Create Project'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { ExternalLink, Wrench } from 'lucide-react';
import type { Tool } from '../../types';
import Badge from '../ui/Badge';
interface Props {
tool: Tool;
onEdit?: () => void;
onDelete?: () => void;
}
export default function ToolCard({ tool, onEdit, onDelete }: Props) {
return (
<div className="group bg-card border border-border rounded-xl p-5 hover:border-[var(--accent)]/40 transition-all duration-200 animate-fade-in">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{tool.is_new && <Badge variant="new">New</Badge>}
<span className="text-xs text-muted">{tool.category}</span>
</div>
<h3 className="font-semibold text-white text-base leading-tight">{tool.name}</h3>
</div>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-[var(--accent-dim)] border border-[var(--accent)]/20">
<Wrench size={15} style={{ color: 'var(--accent)' }} />
</div>
</div>
{tool.description && (
<p className="text-sm text-muted/80 mb-3 line-clamp-3">{tool.description}</p>
)}
{tool.notes && (
<p className="text-xs text-muted/60 italic mb-3 line-clamp-2">{tool.notes}</p>
)}
<div className="flex items-center gap-2 mt-auto pt-3 border-t border-border">
{tool.external_url && (
<a
href={tool.external_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted hover:text-white transition-colors"
>
<ExternalLink size={12} /> Open Tool
</a>
)}
<div className="flex-1" />
{onEdit && (
<button onClick={onEdit} className="text-xs text-muted hover:text-white transition-colors px-2 py-1 rounded hover:bg-white/5">
Edit
</button>
)}
{onDelete && (
<button onClick={onDelete} className="text-xs text-red-400/60 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-red-500/5">
Delete
</button>
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
interface Props {
children: React.ReactNode;
variant?: 'new' | 'active' | 'complete' | 'archived' | 'default';
}
const styles: Record<string, string> = {
new: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
active: 'bg-blue-500/15 text-blue-400 border border-blue-500/30',
complete: 'bg-violet-500/15 text-violet-400 border border-violet-500/30',
archived: 'bg-zinc-500/15 text-zinc-400 border border-zinc-500/30',
default: 'bg-white/5 text-muted border border-border',
};
export default function Badge({ children, variant = 'default' }: Props) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold uppercase tracking-wide ${styles[variant]}`}>
{children}
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Loader2 } from 'lucide-react';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
loading?: boolean;
}
const base = 'inline-flex items-center gap-2 font-medium rounded-lg transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-[var(--accent)] hover:opacity-90 text-white',
secondary: 'bg-white/5 hover:bg-white/10 text-white border border-border',
ghost: 'hover:bg-white/5 text-muted hover:text-white',
danger: 'bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
};
export default function Button({ variant = 'primary', size = 'md', loading, children, disabled, ...props }: Props) {
return (
<button
{...props}
disabled={disabled || loading}
className={`${base} ${variants[variant]} ${sizes[size]} ${props.className || ''}`}
>
{loading && <Loader2 size={14} className="animate-spin" />}
{children}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
interface Props {
title: string;
onClose: () => void;
children: React.ReactNode;
size?: 'md' | 'lg' | 'xl';
}
export default function Modal({ title, onClose, children, size = 'md' }: Props) {
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
const widths = { md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className={`relative w-full ${widths[size]} bg-card border border-border rounded-2xl shadow-2xl animate-slide-up`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-muted hover:text-white transition-colors">
<X size={18} />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
interface Props {
value: number; // 0100
color?: string;
showLabel?: boolean;
size?: 'sm' | 'md';
}
export default function ProgressBar({ value, color, showLabel = false, size = 'sm' }: Props) {
const pct = Math.min(100, Math.max(0, value));
const h = size === 'sm' ? 'h-1.5' : 'h-2.5';
return (
<div className="flex items-center gap-2 w-full">
<div className={`flex-1 bg-border rounded-full overflow-hidden ${h}`}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, background: color || 'var(--accent)' }}
/>
</div>
{showLabel && (
<span className="text-xs font-mono text-muted tabular-nums w-9 text-right">{pct}%</span>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
export interface AuthUser {
id: string;
username: string;
role: 'admin' | 'user';
}
interface AuthState {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (token: string, user: AuthUser) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthState>({
user: null,
token: null,
loading: true,
login: () => {},
logout: () => {},
});
const TOKEN_KEY = 'codedump_token';
const USER_KEY = 'codedump_user';
export function useAuthProvider(): AuthState {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USER_KEY);
if (storedToken && storedUser) {
try {
// Verify token hasn't expired by checking exp claim
const payload = JSON.parse(atob(storedToken.split('.')[1]));
if (payload.exp * 1000 > Date.now()) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
} catch {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
}
setLoading(false);
}, []);
const login = useCallback((newToken: string, newUser: AuthUser) => {
localStorage.setItem(TOKEN_KEY, newToken);
localStorage.setItem(USER_KEY, JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setToken(null);
setUser(null);
}, []);
return { user, token, loading, login, logout };
}
export const useAuth = () => useContext(AuthContext);
+36
View File
@@ -0,0 +1,36 @@
import { useState, useEffect, createContext, useContext } from 'react';
import type { Settings } from '../types';
import { getSettings } from '../api';
const DEFAULT: Settings = {
app_title: 'AI Tools Dashboard',
logo_url: null,
accent_color: '#6366f1',
company_name: 'Your Company',
};
export const SettingsContext = createContext<{
settings: Settings;
reload: () => void;
}>({ settings: DEFAULT, reload: () => {} });
export function useSettingsProvider() {
const [settings, setSettings] = useState<Settings>(DEFAULT);
const reload = () => {
getSettings().then(setSettings).catch(() => {});
};
useEffect(() => { reload(); }, []);
// Apply accent color as CSS variable
useEffect(() => {
document.documentElement.style.setProperty('--accent', settings.accent_color);
// Generate lighter/darker variants
document.documentElement.style.setProperty('--accent-dim', `${settings.accent_color}33`);
}, [settings.accent_color]);
return { settings, reload };
}
export const useSettings = () => useContext(SettingsContext);
+44
View File
@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--accent: #6366f1;
--accent-dim: #6366f133;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
background-color: #0f0f13;
color: white;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2a3a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3a3a4a; }
select option {
background: #1c1c28;
color: white;
}
input[type="range"] {
cursor: pointer;
}
input[type="color"] {
cursor: pointer;
}
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import './index.css';
import { SettingsContext, useSettingsProvider } from './hooks/useSettings';
import { AuthContext, useAuthProvider } from './hooks/useAuth';
import { useAuth } from './hooks/useAuth';
import Layout from './components/layout/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Tools from './pages/Tools';
import SettingsPage from './pages/Settings';
import AdminUsers from './pages/AdminUsers';
// Guard: must be logged in
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return (
<div className="min-h-screen bg-base flex items-center justify-center">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
}
// Guard: must be admin
function RequireAdmin({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return null;
if (!user || user.role !== 'admin') return <Navigate to="/" replace />;
return <>{children}</>;
}
function App() {
const authCtx = useAuthProvider();
const settingsCtx = useSettingsProvider();
return (
<AuthContext.Provider value={authCtx}>
<SettingsContext.Provider value={settingsCtx}>
<BrowserRouter>
<Routes>
{/* Public */}
<Route path="/login" element={<Login />} />
{/* Protected */}
<Route path="/" element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route index element={<Dashboard />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="tools" element={<Tools />} />
{/* Admin-only routes */}
<Route path="settings" element={
<RequireAdmin><SettingsPage /></RequireAdmin>
} />
<Route path="admin/users" element={
<RequireAdmin><AdminUsers /></RequireAdmin>
} />
</Route>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</SettingsContext.Provider>
</AuthContext.Provider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+315
View File
@@ -0,0 +1,315 @@
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Shield, User as UserIcon, Key, RefreshCw } from 'lucide-react';
import type { User } from '../types';
import { getUsers, createUser, updateUser, deleteUser } from '../api';
import Modal from '../components/ui/Modal';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
// ─── PIN input component ──────────────────────────────────────────────────────
function PinInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4);
onChange(v);
};
return (
<div className="relative">
<input
type="text"
inputMode="numeric"
pattern="\d{4}"
maxLength={4}
value={value}
onChange={handleChange}
placeholder="0000"
className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted font-mono tracking-[0.5em] text-center focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex gap-1">
{[0,1,2,3].map((i) => (
<div key={i} className={`w-2 h-2 rounded-full transition-all ${i < value.length ? 'scale-110' : 'bg-border'}`}
style={i < value.length ? { background: 'var(--accent)' } : {}} />
))}
</div>
</div>
);
}
// ─── Create / Edit form ───────────────────────────────────────────────────────
function UserForm({ initial, onSubmit, onCancel }: {
initial?: User;
onSubmit: (d: any) => Promise<void>;
onCancel: () => void;
}) {
const isEdit = Boolean(initial);
const [username, setUsername] = useState(initial?.username || '');
const [role, setRole] = useState<'user' | 'admin'>(initial?.role || 'user');
const [pin, setPin] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!isEdit && !username.trim()) { setError('Username is required'); return; }
if (role === 'user' && !isEdit && (pin.length !== 4)) {
setError('A 4-digit PIN is required'); return;
}
if (role === 'user' && isEdit && pin && pin.length !== 4) {
setError('PIN must be exactly 4 digits'); return;
}
if (role === 'admin' && !isEdit && password.length < 6) {
setError('Password must be at least 6 characters'); return;
}
setLoading(true);
try {
const payload: any = {};
if (!isEdit) {
payload.username = username.trim();
payload.role = role;
}
if (isEdit && username.trim() !== initial?.username) payload.username = username.trim();
if (role === 'user' && pin) payload.pin = pin;
if (role === 'admin' && password) payload.password = password;
await onSubmit(payload);
} catch (err: any) {
setError(err.message || 'Something went wrong');
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>
)}
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Username</label>
<input className={field} value={username} onChange={(e) => setUsername(e.target.value)}
placeholder="e.g. jsmith" disabled={false} />
</div>
{!isEdit && (
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Role</label>
<div className="flex gap-2">
{(['user', 'admin'] as const).map((r) => (
<button key={r} type="button"
onClick={() => { setRole(r); setPin(''); setPassword(''); }}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-all ${
role === r
? 'border-[var(--accent)] bg-[var(--accent-dim)] text-white'
: 'border-border text-muted hover:text-white hover:border-white/20'
}`}
>
{r === 'admin' ? <Shield size={14} /> : <UserIcon size={14} />}
{r.charAt(0).toUpperCase() + r.slice(1)}
</button>
))}
</div>
</div>
)}
{(role === 'user' || initial?.role === 'user') && (
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">
{isEdit ? 'New PIN (leave blank to keep current)' : '4-Digit PIN *'}
</label>
<PinInput value={pin} onChange={setPin} />
<p className="text-xs text-muted/60 mt-1">Numbers only · exactly 4 digits</p>
</div>
)}
{(role === 'admin' || initial?.role === 'admin') && (
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">
{isEdit ? 'New Password (leave blank to keep current)' : 'Password *'}
</label>
<input type="password" className={field} value={password} onChange={(e) => setPassword(e.target.value)}
placeholder={isEdit ? '••••••••' : 'Min 6 characters'} autoComplete="new-password" />
</div>
)}
<div className="flex gap-3 pt-2">
<Button type="submit" loading={loading} className="flex-1">
{isEdit ? 'Save Changes' : 'Create User'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function AdminUsers() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState('');
const load = () => getUsers().then((u) => setUsers(u as User[])).finally(() => setLoading(false));
useEffect(() => { load(); }, []);
const handleCreate = async (data: any) => {
await createUser(data);
setShowCreate(false);
load();
};
const handleEdit = async (data: any) => {
await updateUser(editUser!.id, data);
setEditUser(null);
load();
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError('');
try {
await deleteUser(deleteTarget.id);
setDeleteTarget(null);
load();
} catch (err: any) {
setDeleteError(err.message || 'Failed to delete user');
} finally {
setDeleting(false);
}
};
const admins = users.filter((u) => u.role === 'admin');
const regularUsers = users.filter((u) => u.role === 'user');
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
const UserRow = ({ user }: { user: User }) => (
<div className="flex items-center gap-4 p-4 rounded-xl border border-border bg-card hover:border-[var(--accent)]/30 transition-all animate-fade-in">
<div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shrink-0"
style={{ background: user.role === 'admin' ? '#8b5cf6' : 'var(--accent)' }}>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-white text-sm">{user.username}</span>
{user.role === 'admin'
? <Badge variant="complete">Admin</Badge>
: <Badge variant="active">User</Badge>}
</div>
<p className="text-xs text-muted mt-0.5">
{user.role === 'user' ? <span className="flex items-center gap-1"><Key size={10} /> PIN login</span> : <span className="flex items-center gap-1"><Shield size={10} /> Password login</span>}
{' · '}Added {new Date(user.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="ghost" size="sm" onClick={() => setEditUser(user)}>
<RefreshCw size={13} /> Reset {user.role === 'user' ? 'PIN' : 'Password'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditUser(user)}>
<Pencil size={13} />
</Button>
<Button variant="danger" size="sm" onClick={() => { setDeleteTarget(user); setDeleteError(''); }}>
<Trash2 size={13} />
</Button>
</div>
</div>
);
return (
<div className="p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-2">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-muted text-sm mt-0.5">{users.length} account{users.length !== 1 ? 's' : ''}</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus size={15} /> New User
</Button>
</div>
{/* Info box */}
<div className="bg-[var(--accent-dim)] border border-[var(--accent)]/20 rounded-xl p-4 mb-8 mt-4">
<p className="text-xs text-white/70 leading-relaxed">
<strong className="text-white">Users</strong> log in with a 4-digit PIN from the main login screen and can update projects, upload docs, and manage tools.{' '}
<strong className="text-white">Admins</strong> log in with username &amp; password and also have access to Settings and User Management.
</p>
</div>
{/* Admin accounts */}
<section className="mb-6">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
<Shield size={13} /> Admin Accounts ({admins.length})
</h2>
<div className="space-y-2">
{admins.map((u) => <UserRow key={u.id} user={u} />)}
</div>
</section>
{/* Regular users */}
<section>
<h2 className="text-sm font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
<UserIcon size={13} /> User Accounts ({regularUsers.length})
</h2>
{regularUsers.length === 0 ? (
<div className="text-center py-10 border border-dashed border-border rounded-xl">
<UserIcon size={28} className="mx-auto mb-2 text-muted/30" />
<p className="text-muted text-sm">No user accounts yet.</p>
<button onClick={() => setShowCreate(true)} className="text-xs mt-1 transition-colors" style={{ color: 'var(--accent)' }}>
Create the first user
</button>
</div>
) : (
<div className="space-y-2">
{regularUsers.map((u) => <UserRow key={u.id} user={u} />)}
</div>
)}
</section>
{/* Modals */}
{showCreate && (
<Modal title="Create User" onClose={() => setShowCreate(false)}>
<UserForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
</Modal>
)}
{editUser && (
<Modal title={`Edit — ${editUser.username}`} onClose={() => setEditUser(null)}>
<UserForm initial={editUser} onSubmit={handleEdit} onCancel={() => setEditUser(null)} />
</Modal>
)}
{deleteTarget && (
<Modal title="Delete User" onClose={() => setDeleteTarget(null)}>
<p className="text-muted mb-2">
Permanently delete <strong className="text-white">{deleteTarget.username}</strong>?
</p>
<p className="text-xs text-muted/60 mb-6">This cannot be undone.</p>
{deleteError && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4">{deleteError}</p>
)}
<div className="flex gap-3">
<Button variant="danger" loading={deleting} onClick={handleDelete} className="flex-1">
Delete User
</Button>
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>Cancel</Button>
</div>
</Modal>
)}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { FolderKanban, Wrench, TrendingUp, Sparkles, ArrowRight, CheckCircle2 } from 'lucide-react';
import type { Project, Tool } from '../types';
import { getProjects, getTools } from '../api';
import ProjectCard from '../components/projects/ProjectCard';
import ToolCard from '../components/tools/ToolCard';
import ProgressBar from '../components/ui/ProgressBar';
import { useSettings } from '../hooks/useSettings';
function StatCard({ label, value, icon: Icon, sub }: { label: string; value: string | number; icon: any; sub?: string }) {
return (
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<p className="text-sm text-muted font-medium">{label}</p>
<div className="w-8 h-8 rounded-lg bg-[var(--accent-dim)] border border-[var(--accent)]/20 flex items-center justify-center">
<Icon size={15} style={{ color: 'var(--accent)' }} />
</div>
</div>
<p className="text-3xl font-bold text-white tabular-nums">{value}</p>
{sub && <p className="text-xs text-muted mt-1">{sub}</p>}
</div>
);
}
export default function Dashboard() {
const { settings } = useSettings();
const [projects, setProjects] = useState<Project[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([getProjects(), getTools()])
.then(([p, t]) => { setProjects(p); setTools(t); })
.finally(() => setLoading(false));
}, []);
const active = projects.filter((p) => p.status === 'active');
const complete = projects.filter((p) => p.status === 'complete');
const newTools = tools.filter((t) => t.is_new);
const avgCompletion = projects.length
? Math.round(projects.reduce((s, p) => s + p.completion, 0) / projects.length)
: 0;
const recent = [...projects].slice(0, 6);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-1">{settings.app_title}</h1>
<p className="text-muted text-sm">High-level overview of tools and projects.</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Total Projects" value={projects.length} icon={FolderKanban} sub={`${active.length} active`} />
<StatCard label="Completed" value={complete.length} icon={CheckCircle2} sub="finished projects" />
<StatCard label="Avg Completion" value={`${avgCompletion}%`} icon={TrendingUp} sub="across all projects" />
<StatCard label="Available Tools" value={tools.length} icon={Wrench} sub={`${newTools.length} new`} />
</div>
{/* Overall progress */}
{projects.length > 0 && (
<div className="bg-card border border-border rounded-xl p-5 mb-8">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-white">Overall Portfolio Progress</p>
<span className="text-sm font-mono text-white">{avgCompletion}%</span>
</div>
<ProgressBar value={avgCompletion} size="md" />
</div>
)}
{/* New Tools Spotlight */}
{newTools.length > 0 && (
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
<h2 className="text-base font-semibold text-white">New Tools Available</h2>
</div>
<Link to="/tools" className="text-xs text-muted hover:text-white flex items-center gap-1 transition-colors">
View all <ArrowRight size={12} />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{newTools.slice(0, 3).map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
</section>
)}
{/* Recent Projects */}
<section>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FolderKanban size={16} style={{ color: 'var(--accent)' }} />
<h2 className="text-base font-semibold text-white">Recent Projects</h2>
</div>
<Link to="/projects" className="text-xs text-muted hover:text-white flex items-center gap-1 transition-colors">
View all <ArrowRight size={12} />
</Link>
</div>
{recent.length === 0 ? (
<div className="bg-card border border-border rounded-xl p-12 text-center">
<FolderKanban size={32} className="mx-auto mb-3 text-muted/40" />
<p className="text-muted text-sm">No projects yet.</p>
<Link to="/projects" className="text-xs mt-2 inline-block" style={{ color: 'var(--accent)' }}>
Create your first project
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</section>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Delete, ChevronLeft, Lock, Shield } from 'lucide-react';
import { pinLogin, adminLogin } from '../api';
import { useAuth } from '../hooks/useAuth';
import { useSettings } from '../hooks/useSettings';
type LoginMode = 'pin' | 'admin';
const PAD_KEYS = ['1','2','3','4','5','6','7','8','9','','0','⌫'];
export default function Login() {
const navigate = useNavigate();
const { login, user } = useAuth();
const { settings } = useSettings();
const [mode, setMode] = useState<LoginMode>('pin');
const [pin, setPin] = useState('');
const [adminUsername, setAdminUsername] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [shake, setShake] = useState(false);
useEffect(() => {
if (user) navigate('/', { replace: true });
}, [user, navigate]);
// Auto-submit when 4 digits entered
useEffect(() => {
if (pin.length === 4) submitPin();
}, [pin]);
const triggerShake = () => {
setShake(true);
setTimeout(() => setShake(false), 600);
};
const handleKeyPress = (key: string) => {
setError('');
if (key === '⌫') { setPin((p) => p.slice(0, -1)); return; }
if (pin.length < 4) setPin((p) => p + key);
};
const submitPin = async () => {
if (pin.length !== 4) return;
setLoading(true);
try {
const { token, user: u } = await pinLogin(pin);
login(token, u);
navigate('/', { replace: true });
} catch {
setError('Invalid PIN');
setPin('');
triggerShake();
} finally {
setLoading(false);
}
};
const handleAdminSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!adminUsername || !adminPassword) return;
setLoading(true);
setError('');
try {
const { token, user: u } = await adminLogin(adminUsername, adminPassword);
login(token, u);
navigate('/', { replace: true });
} catch {
setError('Invalid username or password');
triggerShake();
} finally {
setLoading(false);
}
};
const switchMode = (m: LoginMode) => {
setMode(m);
setPin('');
setError('');
};
return (
<div className="min-h-screen bg-base flex flex-col items-center justify-center p-4">
{/* Brand */}
<div className="text-center mb-10">
{settings.logo_url ? (
<img src={settings.logo_url} alt="Logo" className="h-14 mx-auto mb-4 object-contain" />
) : (
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-2xl font-black mx-auto mb-4"
style={{ background: 'var(--accent)' }}
>
{settings.company_name?.charAt(0)?.toUpperCase() || 'C'}
</div>
)}
<h1 className="text-3xl font-black tracking-tight text-white">CODEDUMP</h1>
<p className="text-muted text-sm mt-1">{settings.company_name}</p>
</div>
{/* Card */}
<div
className="w-full max-w-xs bg-card border border-border rounded-2xl shadow-2xl overflow-hidden"
style={{ animation: shake ? 'shake 0.5s ease-in-out' : undefined }}
>
{/* ── PIN pad ───────────────────────────────────────────── */}
{mode === 'pin' && (
<div className="p-6">
<div className="text-center mb-7">
<p className="text-base font-semibold text-white">Enter your PIN</p>
<p className="text-xs text-muted mt-1">4-digit PIN to sign in</p>
</div>
{/* Dot indicator */}
<div className="flex items-center justify-center gap-5 mb-3">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="w-3.5 h-3.5 rounded-full border-2 transition-all duration-150"
style={
i < pin.length
? { background: 'var(--accent)', borderColor: 'var(--accent)', transform: 'scale(1.15)' }
: { borderColor: '#2a2a3a' }
}
/>
))}
</div>
{/* Error */}
<div className="h-5 flex items-center justify-center mb-3">
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
{/* Keypad */}
<div className="grid grid-cols-3 gap-2.5">
{PAD_KEYS.map((key, idx) => (
<button
key={idx}
onClick={() => key && handleKeyPress(key)}
disabled={loading || !key}
className={`h-14 rounded-xl text-lg font-semibold select-none transition-all duration-100
${key === ''
? 'cursor-default'
: key === '⌫'
? 'bg-white/5 hover:bg-white/10 text-muted hover:text-white active:scale-95'
: 'bg-surface hover:bg-white/10 border border-border hover:border-[var(--accent)]/40 text-white active:scale-95 active:bg-[var(--accent-dim)]'
}`}
>
{key === '⌫' ? <Delete size={18} className="mx-auto" /> : key}
</button>
))}
</div>
{loading && (
<div className="flex justify-center mt-5">
<div className="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
</div>
)}
{/* Admin link */}
<div className="mt-6 text-center">
<button
onClick={() => switchMode('admin')}
className="flex items-center gap-1.5 text-xs text-muted/50 hover:text-muted transition-colors mx-auto"
>
<Shield size={11} /> Admin Login
</button>
</div>
</div>
)}
{/* ── Admin login ───────────────────────────────────────── */}
{mode === 'admin' && (
<form onSubmit={handleAdminSubmit} className="p-6">
<button
type="button"
onClick={() => switchMode('pin')}
className="flex items-center gap-1.5 text-xs text-muted hover:text-white transition-colors mb-5"
>
<ChevronLeft size={14} /> Back
</button>
<div className="text-center mb-6">
<div className="w-12 h-12 rounded-full bg-[var(--accent-dim)] border border-[var(--accent)]/30 flex items-center justify-center mx-auto mb-3">
<Lock size={20} style={{ color: 'var(--accent)' }} />
</div>
<p className="text-sm font-semibold text-white">Admin Login</p>
</div>
{error && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4 text-center text-xs">{error}</p>
)}
<div className="space-y-3">
<input
type="text"
autoComplete="username"
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
placeholder="Username"
className="w-full bg-surface border border-border rounded-lg px-3 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
<input
type="password"
autoComplete="current-password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
placeholder="Password"
className="w-full bg-surface border border-border rounded-lg px-3 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<button
type="submit"
disabled={loading || !adminUsername || !adminPassword}
className="w-full mt-4 py-2.5 rounded-lg text-sm font-semibold text-white transition-all disabled:opacity-50"
style={{ background: 'var(--accent)' }}
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
)}
</div>
<p className="text-xs text-muted/30 mt-6">CODEDUMP · Internal Tool</p>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-8px); }
30% { transform: translateX(8px); }
45% { transform: translateX(-6px); }
60% { transform: translateX(6px); }
75% { transform: translateX(-4px); }
90% { transform: translateX(4px); }
}
`}</style>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
ArrowLeft, ExternalLink, Pencil, Trash2, Upload, FileText,
X, Save, FolderOpen, Eye, Code2
} from 'lucide-react';
import type { Project, Document } from '../types';
import { getProject, updateProject, deleteProject, uploadDocument, deleteDocument, getFileUrl } from '../api';
import ProgressBar from '../components/ui/ProgressBar';
import Badge from '../components/ui/Badge';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ProjectForm from '../components/projects/ProjectForm';
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const fileRef = useRef<HTMLInputElement>(null);
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [showEdit, setShowEdit] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeDoc, setActiveDoc] = useState<Document | null>(null);
const [docContent, setDocContent] = useState<string>('');
const [docViewMode, setDocViewMode] = useState<'preview' | 'raw'>('preview');
const [uploading, setUploading] = useState(false);
const [completion, setCompletion] = useState(0);
const [savingCompletion, setSavingCompletion] = useState(false);
const load = () => {
if (!id) return;
getProject(id)
.then((p) => { setProject(p); setCompletion(p.completion); })
.catch(() => navigate('/projects'))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [id]);
const fetchDocContent = async (doc: Document) => {
setActiveDoc(doc);
setDocContent('');
const res = await fetch(getFileUrl(doc.filename));
const text = await res.text();
setDocContent(text);
};
const handleEdit = async (data: Partial<Project>) => {
await updateProject(id!, data);
setShowEdit(false);
load();
};
const handleDelete = async () => {
await deleteProject(id!);
navigate('/projects');
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !id) return;
setUploading(true);
try {
await uploadDocument(id, file);
load();
} finally {
setUploading(false);
e.target.value = '';
}
};
const handleDeleteDoc = async (doc: Document) => {
await deleteDocument(doc.id);
if (activeDoc?.id === doc.id) { setActiveDoc(null); setDocContent(''); }
load();
};
const handleSaveCompletion = async () => {
setSavingCompletion(true);
await updateProject(id!, { completion });
setSavingCompletion(false);
load();
};
if (loading || !project) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
const isMarkdown = (doc: Document) =>
doc.original_name.endsWith('.md') || doc.original_name.endsWith('.txt') || doc.mimetype.startsWith('text/');
return (
<div className="p-8 max-w-7xl mx-auto">
{/* Breadcrumb */}
<Link to="/projects" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-white transition-colors mb-6">
<ArrowLeft size={14} /> Projects
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
{project.is_new && <Badge variant="new">New</Badge>}
<Badge variant={project.status}>{project.status}</Badge>
<span className="text-xs text-muted">{project.category}</span>
</div>
<h1 className="text-2xl font-bold text-white mb-2">{project.name}</h1>
{project.description && <p className="text-muted leading-relaxed">{project.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="secondary" size="sm" onClick={() => setShowEdit(true)}>
<Pencil size={13} /> Edit
</Button>
<Button variant="danger" size="sm" onClick={() => setShowDeleteConfirm(true)}>
<Trash2 size={13} /> Delete
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column — metadata */}
<div className="space-y-4">
{/* Completion editor */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-medium text-white mb-3">Completion</p>
<div className="flex items-center justify-between mb-2">
<span className="text-3xl font-bold text-white tabular-nums">{completion}%</span>
</div>
<ProgressBar value={completion} color={project.accent_color} size="md" />
<input
type="range" min={0} max={100} value={completion}
onChange={(e) => setCompletion(Number(e.target.value))}
className="w-full mt-3 accent-[var(--accent)]"
/>
{completion !== project.completion && (
<Button size="sm" className="w-full mt-2" loading={savingCompletion} onClick={handleSaveCompletion}>
<Save size={12} /> Save
</Button>
)}
</div>
{/* Links */}
{(project.external_url || project.drive_url) && (
<div className="bg-card border border-border rounded-xl p-5 space-y-3">
<p className="text-sm font-medium text-white">Links</p>
{project.external_url && (
<a href={project.external_url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted hover:text-white transition-colors">
<ExternalLink size={13} style={{ color: 'var(--accent)' }} />
{project.external_url.length > 38 ? project.external_url.slice(0, 35) + '…' : project.external_url}
</a>
)}
{project.drive_url && (
<a href={project.drive_url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted hover:text-white transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--accent)' }}>
<path d="M4.433 22l3.464-6h12l-3.464 6H4.433zM0 15L6 3.5 9.464 9.5 3.464 20.5 0 15zM14.536 9.5L11.072 3.5H23.072L19.608 9.5H14.536z" />
</svg>
Google Drive
</a>
)}
</div>
)}
{/* Tags */}
{project.tags.length > 0 && (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-medium text-white mb-3">Tags</p>
<div className="flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span key={tag} className="text-xs px-2.5 py-1 rounded-full bg-white/5 text-muted border border-border">
{tag}
</span>
))}
</div>
</div>
)}
{/* Documents list */}
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-white">Documents</p>
<button onClick={() => fileRef.current?.click()}
className="flex items-center gap-1.5 text-xs text-muted hover:text-white transition-colors"
disabled={uploading}
>
<Upload size={12} /> {uploading ? 'Uploading…' : 'Upload'}
</button>
<input ref={fileRef} type="file" className="hidden"
accept=".md,.txt,.pdf,.png,.jpg,.jpeg,.gif,.svg"
onChange={handleUpload}
/>
</div>
{project.documents?.length === 0 ? (
<p className="text-xs text-muted/60">No documents yet.</p>
) : (
<ul className="space-y-1">
{project.documents?.map((doc) => (
<li key={doc.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors group ${
activeDoc?.id === doc.id ? 'bg-[var(--accent-dim)]' : 'hover:bg-white/5'
}`}
onClick={() => isMarkdown(doc) || doc.mimetype.startsWith('text/') ? fetchDocContent(doc) : window.open(getFileUrl(doc.filename), '_blank')}
>
<FileText size={12} className="text-muted shrink-0" />
<span className="text-xs text-white truncate flex-1">{doc.original_name}</span>
<button onClick={(e) => { e.stopPropagation(); handleDeleteDoc(doc); }}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-red-400 transition-all">
<X size={11} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
{/* Right column — doc viewer */}
<div className="lg:col-span-2">
{activeDoc ? (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-medium text-white">{activeDoc.original_name}</span>
<div className="flex items-center gap-1">
<button onClick={() => setDocViewMode('preview')}
className={`p-1.5 rounded transition-colors ${docViewMode === 'preview' ? 'bg-[var(--accent-dim)] text-white' : 'text-muted hover:text-white'}`}>
<Eye size={14} />
</button>
<button onClick={() => setDocViewMode('raw')}
className={`p-1.5 rounded transition-colors ${docViewMode === 'raw' ? 'bg-[var(--accent-dim)] text-white' : 'text-muted hover:text-white'}`}>
<Code2 size={14} />
</button>
<button onClick={() => setActiveDoc(null)} className="p-1.5 rounded text-muted hover:text-white transition-colors ml-1">
<X size={14} />
</button>
</div>
</div>
<div className="p-6 overflow-auto max-h-[70vh]">
{docViewMode === 'preview' ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-zinc-300 prose-p:leading-relaxed
prose-a:text-[var(--accent)] prose-a:no-underline hover:prose-a:underline
prose-code:bg-white/10 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono
prose-pre:bg-surface prose-pre:border prose-pre:border-border
prose-blockquote:border-l-[var(--accent)] prose-blockquote:text-muted
prose-li:text-zinc-300 prose-strong:text-white
prose-table:text-sm prose-th:text-muted prose-td:text-zinc-300
prose-hr:border-border">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{docContent}</ReactMarkdown>
</div>
) : (
<pre className="text-xs text-zinc-300 font-mono whitespace-pre-wrap">{docContent}</pre>
)}
</div>
</div>
) : (
<div className="bg-card border border-dashed border-border rounded-xl p-16 text-center h-full flex flex-col items-center justify-center">
<FolderOpen size={40} className="text-muted/30 mb-4" />
<p className="text-muted text-sm">Select a document to preview it here</p>
<button onClick={() => fileRef.current?.click()}
className="mt-3 text-xs transition-colors" style={{ color: 'var(--accent)' }}>
or upload a new file
</button>
</div>
)}
</div>
</div>
{/* Edit modal */}
{showEdit && (
<Modal title="Edit Project" onClose={() => setShowEdit(false)} size="lg">
<ProjectForm initial={project} onSubmit={handleEdit} onCancel={() => setShowEdit(false)} />
</Modal>
)}
{/* Delete confirm */}
{showDeleteConfirm && (
<Modal title="Delete Project" onClose={() => setShowDeleteConfirm(false)}>
<p className="text-muted mb-6">This will permanently delete <strong className="text-white">{project.name}</strong> and all its documents. This cannot be undone.</p>
<div className="flex gap-3">
<Button variant="danger" onClick={handleDelete} className="flex-1">Delete Project</Button>
<Button variant="secondary" onClick={() => setShowDeleteConfirm(false)}>Cancel</Button>
</div>
</Modal>
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Search, Filter } from 'lucide-react';
import type { Project } from '../types';
import { getProjects, createProject, updateProject, deleteProject } from '../api';
import ProjectCard from '../components/projects/ProjectCard';
import ProjectForm from '../components/projects/ProjectForm';
import Modal from '../components/ui/Modal';
import Button from '../components/ui/Button';
const STATUS_OPTS = ['all', 'active', 'complete', 'archived'] as const;
export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const load = () => getProjects().then(setProjects).finally(() => setLoading(false));
useEffect(() => { load(); }, []);
const categories = useMemo(() => {
const cats = new Set(projects.map((p) => p.category));
return ['all', ...Array.from(cats).sort()];
}, [projects]);
const filtered = useMemo(() => projects.filter((p) => {
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase()) ||
p.tags.some((t) => t.toLowerCase().includes(search.toLowerCase()));
const matchStatus = statusFilter === 'all' || p.status === statusFilter;
const matchCat = categoryFilter === 'all' || p.category === categoryFilter;
return matchSearch && matchStatus && matchCat;
}), [projects, search, statusFilter, categoryFilter]);
const handleCreate = async (data: Partial<Project>) => {
await createProject(data);
setShowCreate(false);
load();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Projects</h1>
<p className="text-muted text-sm mt-0.5">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus size={15} /> New Project
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative flex-1 min-w-48">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects..."
className="w-full bg-card border border-border rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<div className="flex items-center gap-2">
<Filter size={13} className="text-muted shrink-0" />
<div className="flex bg-card border border-border rounded-lg overflow-hidden">
{STATUS_OPTS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-2 text-xs font-medium capitalize transition-colors ${
statusFilter === s ? 'bg-[var(--accent)] text-white' : 'text-muted hover:text-white'
}`}
>
{s}
</button>
))}
</div>
</div>
{categories.length > 2 && (
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-card border border-border rounded-lg px-3 py-2 text-sm text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
>
{categories.map((c) => <option key={c} value={c}>{c === 'all' ? 'All Categories' : c}</option>)}
</select>
)}
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="text-muted">
{projects.length === 0 ? 'No projects yet. Create your first one.' : 'No projects match your filters.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((p) => (
<ProjectCard key={p.id} project={p} />
))}
</div>
)}
{/* Create modal */}
{showCreate && (
<Modal title="New Project" onClose={() => setShowCreate(false)} size="lg">
<ProjectForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
</Modal>
)}
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { useState, useEffect, useRef } from 'react';
import { Upload, Check, Palette } from 'lucide-react';
import type { Settings } from '../types';
import { getSettings, updateSettings, uploadLogo } from '../api';
import Button from '../components/ui/Button';
import { useSettings } from '../hooks/useSettings';
const ACCENT_PRESETS = [
{ label: 'Indigo', value: '#6366f1' },
{ label: 'Violet', value: '#8b5cf6' },
{ label: 'Pink', value: '#ec4899' },
{ label: 'Orange', value: '#f97316' },
{ label: 'Emerald', value: '#10b981' },
{ label: 'Cyan', value: '#06b6d4' },
{ label: 'Yellow', value: '#eab308' },
{ label: 'Red', value: '#ef4444' },
];
export default function SettingsPage() {
const { reload } = useSettings();
const logoRef = useRef<HTMLInputElement>(null);
const [settings, setSettings] = useState<Settings>({ app_title: '', logo_url: null, accent_color: '#6366f1', company_name: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
getSettings().then(setSettings).finally(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
await updateSettings(settings);
reload();
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
try {
const { logo_url } = await uploadLogo(file);
setSettings((s) => ({ ...s, logo_url }));
reload();
} finally {
setUploadingLogo(false);
e.target.value = '';
}
};
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-white mb-1">Settings</h1>
<p className="text-muted text-sm mb-8">Customize your dashboard branding and appearance.</p>
<form onSubmit={handleSave} className="space-y-8">
{/* Branding */}
<section>
<h2 className="text-base font-semibold text-white mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 rounded-sm inline-block" style={{ background: 'var(--accent)' }} />
Branding
</h2>
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
{/* Logo */}
<div>
<label className="text-xs text-muted font-medium mb-2 block">Company Logo</label>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl border border-border bg-surface flex items-center justify-center overflow-hidden">
{settings.logo_url ? (
<img src={settings.logo_url} alt="Logo" className="w-full h-full object-contain p-1" />
) : (
<span className="text-2xl font-bold" style={{ color: 'var(--accent)' }}>
{settings.company_name?.charAt(0)?.toUpperCase() || '?'}
</span>
)}
</div>
<div className="space-y-2">
<Button type="button" variant="secondary" size="sm" loading={uploadingLogo} onClick={() => logoRef.current?.click()}>
<Upload size={13} /> Upload Logo
</Button>
<p className="text-xs text-muted">PNG, JPG, SVG · max 5 MB</p>
<input ref={logoRef} type="file" className="hidden" accept=".png,.jpg,.jpeg,.svg,.gif,.webp" onChange={handleLogoUpload} />
</div>
</div>
<div className="mt-3">
<label className="text-xs text-muted font-medium mb-1.5 block">Or paste a logo URL</label>
<input className={field} value={settings.logo_url || ''} onChange={(e) => setSettings({ ...settings, logo_url: e.target.value || null })} placeholder="https://..." type="url" />
</div>
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">App Title</label>
<input className={field} value={settings.app_title} onChange={(e) => setSettings({ ...settings, app_title: e.target.value })} placeholder="AI Tools Dashboard" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Company Name</label>
<input className={field} value={settings.company_name} onChange={(e) => setSettings({ ...settings, company_name: e.target.value })} placeholder="Your Company" />
</div>
</div>
</section>
{/* Theme */}
<section>
<h2 className="text-base font-semibold text-white mb-4 flex items-center gap-2">
<Palette size={16} style={{ color: 'var(--accent)' }} />
Theme
</h2>
<div className="bg-card border border-border rounded-xl p-6">
<label className="text-xs text-muted font-medium mb-3 block">Accent Color</label>
<div className="flex flex-wrap gap-3 mb-4">
{ACCENT_PRESETS.map(({ label, value }) => (
<button
key={value}
type="button"
onClick={() => setSettings({ ...settings, accent_color: value })}
title={label}
className="relative w-9 h-9 rounded-full border-2 transition-all"
style={{ background: value, borderColor: settings.accent_color === value ? 'white' : 'transparent' }}
>
{settings.accent_color === value && (
<Check size={14} className="absolute inset-0 m-auto text-white" />
)}
</button>
))}
<div className="relative">
<input
type="color"
value={settings.accent_color}
onChange={(e) => setSettings({ ...settings, accent_color: e.target.value })}
className="w-9 h-9 rounded-full cursor-pointer border-2 border-border p-0.5 bg-transparent"
title="Custom color"
/>
</div>
</div>
{/* Preview */}
<div className="mt-4 p-4 bg-surface border border-border rounded-lg">
<p className="text-xs text-muted mb-3">Preview</p>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 bg-border rounded-full overflow-hidden">
<div className="h-full w-2/3 rounded-full transition-all" style={{ background: settings.accent_color }} />
</div>
<span className="text-xs font-mono text-white">67%</span>
</div>
<div className="flex gap-2 mt-3">
<span className="text-[11px] px-2 py-0.5 rounded font-semibold" style={{ background: `${settings.accent_color}22`, color: settings.accent_color, border: `1px solid ${settings.accent_color}44` }}>
NEW
</span>
<button className="text-xs px-3 py-1 rounded-lg text-white" style={{ background: settings.accent_color }}>
Action
</button>
</div>
</div>
</div>
</section>
<Button type="submit" loading={saving} className="w-full">
{saved ? <><Check size={15} /> Saved!</> : 'Save Settings'}
</Button>
</form>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Search, Sparkles } from 'lucide-react';
import type { Tool } from '../types';
import { getTools, createTool, updateTool, deleteTool } from '../api';
import ToolCard from '../components/tools/ToolCard';
import Modal from '../components/ui/Modal';
import Button from '../components/ui/Button';
const CATEGORIES = ['General', 'AI/ML', 'Automation', 'Data', 'DevOps', 'Frontend', 'Backend', 'Research', 'Other'];
function ToolForm({ initial, onSubmit, onCancel }: {
initial?: Partial<Tool>;
onSubmit: (d: Partial<Tool>) => Promise<void>;
onCancel: () => void;
}) {
const [form, setForm] = useState({
name: initial?.name || '',
description: initial?.description || '',
category: initial?.category || 'General',
external_url: initial?.external_url || '',
is_new: initial?.is_new ?? true,
notes: initial?.notes || '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) { setError('Name is required'); return; }
setLoading(true); setError('');
try {
await onSubmit({ ...form, external_url: form.external_url || null });
} catch (err: any) {
setError(err.message || 'Something went wrong');
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>}
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Tool Name *</label>
<input className={field} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Claude API" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Category</label>
<select className={field} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Description</label>
<textarea className={`${field} resize-none`} rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this tool do?" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">External URL</label>
<input className={field} value={form.external_url} onChange={(e) => setForm({ ...form, external_url: e.target.value })} placeholder="https://..." type="url" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Notes</label>
<textarea className={`${field} resize-none`} rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Usage notes, tips, caveats..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="tool_is_new" checked={form.is_new} onChange={(e) => setForm({ ...form, is_new: e.target.checked })} className="accent-[var(--accent)] w-4 h-4" />
<label htmlFor="tool_is_new" className="text-sm text-muted cursor-pointer">Mark as New</label>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={loading} className="flex-1">
{initial?.id ? 'Save Changes' : 'Add Tool'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
export default function Tools() {
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editTool, setEditTool] = useState<Tool | null>(null);
const [search, setSearch] = useState('');
const [catFilter, setCatFilter] = useState('all');
const [newOnly, setNewOnly] = useState(false);
const load = () => getTools().then(setTools).finally(() => setLoading(false));
useEffect(() => { load(); }, []);
const cats = useMemo(() => {
const s = new Set(tools.map((t) => t.category));
return ['all', ...Array.from(s).sort()];
}, [tools]);
const filtered = useMemo(() => tools.filter((t) => {
const matchSearch = !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.description.toLowerCase().includes(search.toLowerCase());
const matchCat = catFilter === 'all' || t.category === catFilter;
return matchSearch && matchCat && (!newOnly || t.is_new);
}), [tools, search, catFilter, newOnly]);
const newCount = tools.filter((t) => t.is_new).length;
const handleCreate = async (data: Partial<Tool>) => {
await createTool(data);
setShowCreate(false);
load();
};
const handleEdit = async (data: Partial<Tool>) => {
await updateTool(editTool!.id, data);
setEditTool(null);
load();
};
const handleDelete = async (tool: Tool) => {
if (!confirm(`Delete "${tool.name}"?`)) return;
await deleteTool(tool.id);
load();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Tools</h1>
<p className="text-muted text-sm mt-0.5">{tools.length} tool{tools.length !== 1 ? 's' : ''}{newCount > 0 && ` · ${newCount} new`}</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus size={15} /> Add Tool
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative flex-1 min-w-48">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
className="w-full bg-card border border-border rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<select
value={catFilter}
onChange={(e) => setCatFilter(e.target.value)}
className="bg-card border border-border rounded-lg px-3 py-2 text-sm text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
>
{cats.map((c) => <option key={c} value={c}>{c === 'all' ? 'All Categories' : c}</option>)}
</select>
<button
onClick={() => setNewOnly(!newOnly)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border transition-colors ${newOnly ? 'bg-[var(--accent-dim)] border-[var(--accent)]/30 text-white' : 'bg-card border-border text-muted hover:text-white'}`}
>
<Sparkles size={13} /> New Only
</button>
</div>
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="text-muted">{tools.length === 0 ? 'No tools yet. Add your first one.' : 'No tools match your filters.'}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
onEdit={() => setEditTool(tool)}
onDelete={() => handleDelete(tool)}
/>
))}
</div>
)}
{showCreate && (
<Modal title="Add Tool" onClose={() => setShowCreate(false)}>
<ToolForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
</Modal>
)}
{editTool && (
<Modal title="Edit Tool" onClose={() => setEditTool(null)}>
<ToolForm initial={editTool} onSubmit={handleEdit} onCancel={() => setEditTool(null)} />
</Modal>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
export interface Project {
id: string;
name: string;
description: string;
category: string;
status: 'active' | 'complete' | 'archived';
completion: number;
external_url: string | null;
drive_url: string | null;
tags: string[];
accent_color: string;
is_new: boolean;
created_at: string;
updated_at: string;
doc_count?: number;
documents?: Document[];
}
export interface Document {
id: string;
project_id: string;
filename: string;
original_name: string;
mimetype: string;
created_at: string;
}
export interface Tool {
id: string;
name: string;
description: string;
category: string;
external_url: string | null;
is_new: boolean;
added_at: string;
notes: string;
}
export interface Settings {
app_title: string;
logo_url: string | null;
accent_color: string;
company_name: string;
}
export type ProjectStatus = 'active' | 'complete' | 'archived';
export interface User {
id: string;
username: string;
role: 'admin' | 'user';
created_at: string;
}
+28
View File
@@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
base: '#0f0f13',
surface: '#16161f',
card: '#1c1c28',
border: '#2a2a3a',
muted: '#6b7280',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.25s ease-out',
},
keyframes: {
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
slideUp: { from: { opacity: '0', transform: 'translateY(8px)' }, to: { opacity: '1', transform: 'translateY(0)' } },
},
},
},
plugins: [],
};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});