initial design fix
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
interface Props {
|
||||
value: number; // 0–100
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user