initial design fix

This commit is contained in:
2026-04-22 21:26:59 -05:00
parent 874cbfb6a8
commit 0d44d2cd90
45 changed files with 3509 additions and 84 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
*/node_modules
*/dist
data
.git
*.log
+53
View File
@@ -0,0 +1,53 @@
# ── Stage 1: Build client ───────────────────────────────────────────────────
FROM node:20-alpine AS client-builder
WORKDIR /build
COPY package.json ./
COPY client/package.json ./client/
COPY server/package.json ./server/
RUN npm install --workspace=client
COPY client/ ./client/
RUN npm run build --workspace=client
# ── Stage 2: Build server ───────────────────────────────────────────────────
FROM node:20-alpine AS server-builder
WORKDIR /build
COPY package.json ./
COPY server/package.json ./server/
RUN npm install --workspace=server
COPY server/ ./server/
RUN npm run build --workspace=server
# ── Stage 3: Runtime ────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/data
ENV MAX_UPLOAD_MB=50
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=codedump2024
ENV JWT_SECRET=changeme-use-a-long-random-string
# Install production deps only
COPY package.json ./
COPY server/package.json ./server/
RUN npm install --workspace=server --omit=dev
# Copy built artifacts
COPY --from=server-builder /build/server/dist ./server/dist
COPY --from=client-builder /build/client/dist ./client/dist
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://localhost:3000/api/settings || exit 1
CMD ["node", "server/dist/index.js"]
+164 -84
View File
@@ -1,105 +1,185 @@
# Install and Copy Guide # Unraid Install Guide — CODEDUMP
Use this guide to copy the instruction suite into another repository without bloating the destination or confusing the agent. ## Prerequisites
## Recommended Install - Unraid 6.10 or later
- Docker enabled (default on all modern Unraid installs)
- Community Applications plugin installed (recommended, not required)
Copy these items into the target repository root: ---
- `AGENTS.md` ## Option A — Community Applications (Easiest)
- `DEPLOYMENT-PROFILE.md`
- `SKILLS.md`
- `PROJECT-PROFILE-WORKBOOK.md`
- `ROUTING-EXAMPLES.md`
- `hubs/`
- `skills/`
Optional: > Use this if you have the CA plugin installed.
- `README.md` if you want human-facing explanation of the bundle in the destination repo 1. Open the **Apps** tab in the Unraid web UI.
2. Search for **CODEDUMP**.
3. Click **Install** and fill in the variables described in the [Variables](#variables) section below.
4. Click **Apply**.
## Fast Copy Workflow ---
From this repository root, copy the suite into another repo root while excluding `.git`: ## Option B — Manual Docker Container (No CA Plugin)
```powershell ### Step 1 — Open the Docker tab
Copy-Item AGENTS.md,DEPLOYMENT-PROFILE.md,SKILLS.md,PROJECT-PROFILE-WORKBOOK.md,ROUTING-EXAMPLES.md -Destination <target-repo>
Copy-Item hubs -Destination <target-repo> -Recurse In the Unraid web UI, click **Docker** in the top navigation bar.
Copy-Item skills -Destination <target-repo> -Recurse
### Step 2 — Add a new container
Click **Add Container** at the bottom of the page.
### Step 3 — Fill in Basic Settings
| Field | Value |
|---|---|
| **Name** | `codedump` |
| **Repository** | `your-registry/codedump:latest` *(or build locally — see below)* |
| **Network Type** | `bridge` |
| **Console shell command** | `sh` |
> **Building locally:** If you cloned this repo to Unraid, open a terminal and run:
> ```bash
> cd /path/to/ai-tools-dashboard
> docker build -t codedump:local .
> ```
> Then set **Repository** to `codedump:local`.
### Step 4 — Port Mapping
Click **Add another Path, Port, Variable, Label or Device****Port**.
| Field | Value |
|---|---|
| **Name** | `WebUI` |
| **Container Port** | `3000` |
| **Host Port** | `3000` *(or any open port on your Unraid server)* |
| **Connection Type** | `TCP` |
### Step 5 — Volume (Persistent Data)
Click **Add another Path, Port, Variable, Label or Device****Path**.
| Field | Value |
|---|---|
| **Name** | `Data` |
| **Container Path** | `/data` |
| **Host Path** | `/mnt/user/appdata/codedump` |
| **Access Mode** | `Read/Write` |
> This single volume stores the **SQLite database**, all **uploaded files**, and **user accounts**.
> Unraid creates the directory automatically on first run.
### Step 6 — Environment Variables {#variables}
For each variable, click **Add another Path, Port, Variable, Label or Device****Variable**.
| Variable | Default | Required | Description |
|---|---|---|---|
| `PORT` | `3000` | No | Port the app listens on inside the container. Match to your Container Port above. |
| `DATA_DIR` | `/data` | No | Path inside the container for persistent data. Do not change unless you remapped the volume. |
| `MAX_UPLOAD_MB` | `50` | No | Maximum upload size in MB for documents and logos. |
| `ADMIN_USERNAME` | `admin` | **Yes** | Username for the bootstrap admin account. Set this before first launch. |
| `ADMIN_PASSWORD` | `codedump2024` | **Yes** | Password for the bootstrap admin account. **Change this to something strong.** |
| `JWT_SECRET` | *(insecure default)* | **Yes** | Secret used to sign login tokens. Set to a long random string (32+ chars). |
> **Security note:** `ADMIN_PASSWORD` and `JWT_SECRET` must be changed from their defaults before exposing CODEDUMP to your network. The bootstrap admin is only created once — changing `ADMIN_PASSWORD` after first launch has no effect on the existing account (use the Admin → Users page instead).
### Step 7 — WebUI Link (Optional but Recommended)
| Field | Value |
|---|---|
| **WebUI** | `http://[IP]:[PORT:3000]` |
This adds a clickable **WebUI** button on the Docker tab.
### Step 8 — Apply
Click **Apply**. Unraid will pull/build the image and start the container.
---
## First Launch & Login
1. Open `http://YOUR-UNRAID-IP:3000` in your browser.
2. You will be redirected to the **login page**.
3. Click **Admin Login** at the bottom of the login screen.
4. Enter the `ADMIN_USERNAME` and `ADMIN_PASSWORD` you set in the environment variables.
5. Once in, navigate to **Admin → Users** to create user accounts for your team.
### User Account Types
| Type | Login Method | Access |
|---|---|---|
| **Admin** | Username + Password | Full access including Settings and User Management |
| **User** | Select name → 4-digit PIN | Projects, Tools, Documents — all read/write |
### Creating Users (Admin only)
1. Click **Users** in the sidebar (Admin section).
2. Click **New User**.
3. Enter a username, select role **User**, and assign a **4-digit PIN**.
4. The user's name will appear on the login screen — they select it and enter their PIN.
---
## Updating
### Registry image:
1. Go to **Docker** tab → click the container icon → **Update** (or Force Update).
2. The `/data` volume is preserved — database, uploads, and user accounts are safe.
### Locally built image:
```bash
cd /path/to/ai-tools-dashboard
git pull
docker build -t codedump:local .
```
Then restart the container from the Unraid Docker tab.
---
## Backup
Everything lives in one directory:
```
/mnt/user/appdata/codedump/
├── dashboard.db ← SQLite database (projects, tools, settings, users)
└── uploads/ ← Uploaded files (logos, markdown docs)
``` ```
If you also want the explanatory readme: Back this up with Unraid's **Appdata Backup** plugin or any solution that covers `/mnt/user/appdata`.
```powershell ---
Copy-Item README.md -Destination <target-repo>
## Generating a Strong JWT_SECRET
Run this in an Unraid terminal or any Linux shell:
```bash
openssl rand -hex 32
``` ```
## Prefill Workflow Paste the output as the value of `JWT_SECRET`.
Before deployment: ---
1. Fill out `PROJECT-PROFILE-WORKBOOK.md`. ## Port Conflicts
2. Translate the answers into agent-facing defaults in `DEPLOYMENT-PROFILE.md`.
3. Keep the deployment profile concise so it can be read early without wasting context.
4. Copy the suite with the filled-in deployment profile included.
At runtime: Change **Host Port** to an unused port (e.g. `3100`) and update the WebUI link to match.
1. The agent reads `AGENTS.md`. ---
2. The agent reads `DEPLOYMENT-PROFILE.md` if it is filled in.
3. The agent reads `SKILLS.md`.
4. The agent opens the relevant hub and specialized skill files only as needed.
## Minimal Install ## Troubleshooting
If the target repository wants the smallest useful setup, copy: | Symptom | Fix |
|---|---|
| Stuck on login / "Session expired" | Clear browser localStorage and reload. |
| "No user accounts yet" on login | Log in as admin first, then create users via Admin → Users. |
| Admin can't log in after reinstall | If `/data` was wiped, bootstrap re-runs on next start using the current env vars. |
| Container exits immediately | Check Docker logs — usually a permissions issue on `/data`. |
| Can't upload files | Verify `MAX_UPLOAD_MB` and that the host path is writable. |
| Blank page / 404 | Wait 15 seconds after start, then refresh. |
- `AGENTS.md` View container logs: **Docker tab** → click the container icon → **Logs**.
- `DEPLOYMENT-PROFILE.md`
- `SKILLS.md`
- `hubs/`
- only the skill files the team expects to use often
Good minimal baseline:
- `skills/software/repo-exploration.md`
- `skills/software/feature-implementation.md`
- `skills/software/test-strategy.md`
- `skills/software/code-review.md`
- `skills/debugging/bug-triage.md`
- `skills/debugging/debugging-workflow.md`
- `skills/documentation/technical-docs.md`
- `skills/ui-ux/ux-review.md`
## How To Customize Safely
- Keep `AGENTS.md` short and stable; put detail in hubs and skill files.
- Add repo-specific instructions near the top of `AGENTS.md` or in existing repo instruction files.
- Treat `DEPLOYMENT-PROFILE.md` as the canonical place for staged build, tool, environment, and workflow defaults.
- Prefer editing hub routing before adding more root-level rules.
- Add new skill files only when they introduce a distinct workflow, risk area, or deliverable.
- If two skills overlap heavily, merge them or make the routing distinction explicit.
## Context Limit Guidance
- Do not instruct the agent to load the entire `skills/` tree on every task.
- Keep the default path to one hub plus two to four specialized skills.
- Reserve four to six skills for large cross-functional tasks.
- Use the routing examples to confirm the suite still routes clearly after customization.
## Updating The Bundle
- Pull improvements from this source repository into destination repos periodically.
- Refill the workbook and refresh the deployment profile when your preferred defaults change materially.
- Review local customizations before overwriting shared files.
- If a destination repo has stronger local conventions, keep those and treat this suite as the fallback layer.
## Suggested Verification After Copying
Check that the destination repo can answer these questions clearly:
1. Where does the agent start? `AGENTS.md`
2. Where does the agent get preloaded defaults? `DEPLOYMENT-PROFILE.md`
3. Where does the agent look for available skills? `SKILLS.md`
4. How does the agent decide what to read next? Deployment profile, hubs, and routing examples
5. Does the repo still prioritize local instructions over the generic bundle? It should
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CODEDUMP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "ai-tools-dashboard-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"lucide-react": "^0.364.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.2",
"vite": "^5.2.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+94
View File
@@ -0,0 +1,94 @@
import type { Project, Tool, Settings } from '../types';
const BASE = '/api';
const TOKEN_KEY = 'codedump_token';
function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
async function req<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (options.body && typeof options.body === 'string') headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401) {
// Token expired — clear storage and reload to login
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('codedump_user');
window.location.href = '/login';
throw new Error('Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Auth
export const pinLogin = (pin: string) =>
req<{ token: string; user: any }>('/auth/pin', { method: 'POST', body: JSON.stringify({ pin }) });
export const adminLogin = (username: string, password: string) =>
req<{ token: string; user: any }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
// Users (admin only)
export const getUsers = () => req<any[]>('/users');
export const createUser = (data: any) =>
req<any>('/users', { method: 'POST', body: JSON.stringify(data) });
export const updateUser = (id: string, data: any) =>
req<any>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteUser = (id: string) => req<void>(`/users/${id}`, { method: 'DELETE' });
// Projects
export const getProjects = () => req<Project[]>('/projects');
export const getProject = (id: string) => req<Project>(`/projects/${id}`);
export const createProject = (data: Partial<Project>) =>
req<Project>('/projects', { method: 'POST', body: JSON.stringify(data) });
export const updateProject = (id: string, data: Partial<Project>) =>
req<Project>(`/projects/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteProject = (id: string) => req<void>(`/projects/${id}`, { method: 'DELETE' });
// Tools
export const getTools = () => req<Tool[]>('/tools');
export const createTool = (data: Partial<Tool>) =>
req<Tool>('/tools', { method: 'POST', body: JSON.stringify(data) });
export const updateTool = (id: string, data: Partial<Tool>) =>
req<Tool>(`/tools/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteTool = (id: string) => req<void>(`/tools/${id}`, { method: 'DELETE' });
// Uploads
export const uploadDocument = (projectId: string, file: File) => {
const form = new FormData();
form.append('file', file);
const token = getToken();
return fetch(`${BASE}/uploads/projects/${projectId}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
}).then((r) => r.json());
};
export const deleteDocument = (id: string) => req<void>(`/uploads/documents/${id}`, { method: 'DELETE' });
export const getFileUrl = (filename: string) => `${BASE}/uploads/${filename}`;
// Settings
export const getSettings = () => req<Settings>('/settings');
export const updateSettings = (data: Partial<Settings>) =>
req<Settings>('/settings', { method: 'PUT', body: JSON.stringify(data) });
export const uploadLogo = (file: File) => {
const form = new FormData();
form.append('logo', file);
const token = getToken();
return fetch(`${BASE}/settings/logo`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
}).then((r) => r.json());
};
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
export default function Layout() {
return (
<div className="flex min-h-screen bg-base text-white">
<Sidebar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Wrench, Settings, Users, LogOut, Shield } from 'lucide-react';
import { useSettings } from '../../hooks/useSettings';
import { useAuth } from '../../hooks/useAuth';
const userLinks = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/tools', icon: Wrench, label: 'Tools' },
];
const adminLinks = [
{ to: '/settings', icon: Settings, label: 'Settings' },
{ to: '/admin/users', icon: Users, label: 'Users' },
];
export default function Sidebar() {
const { settings } = useSettings();
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
const isAdmin = user?.role === 'admin';
return (
<aside className="flex flex-col w-60 min-h-screen bg-surface border-r border-border shrink-0">
{/* Logo / Brand */}
<div className="flex items-center gap-3 px-5 py-5 border-b border-border">
{settings.logo_url ? (
<img src={settings.logo_url} alt="Logo" className="h-8 w-8 object-contain rounded" />
) : (
<div
className="h-8 w-8 rounded-lg flex items-center justify-center text-white text-sm font-bold shrink-0"
style={{ background: 'var(--accent)' }}
>
{settings.company_name?.charAt(0)?.toUpperCase() || 'C'}
</div>
)}
<div className="overflow-hidden">
<p className="text-white font-black text-sm leading-tight tracking-tight">CODEDUMP</p>
<p className="text-muted text-xs truncate">{settings.company_name}</p>
</div>
</div>
{/* Main nav */}
<nav className="flex-1 px-3 py-4 space-y-1">
{userLinks.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? 'text-white bg-[var(--accent-dim)] border border-[var(--accent)]/30'
: 'text-muted hover:text-white hover:bg-card'
}`
}
>
{({ isActive }) => (
<>
<Icon size={16} className="shrink-0" style={isActive ? { color: 'var(--accent)' } : {}} />
{label}
</>
)}
</NavLink>
))}
{/* Admin-only section */}
{isAdmin && (
<>
<div className="pt-3 pb-1 px-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted/50 flex items-center gap-1.5">
<Shield size={9} /> Admin
</p>
</div>
{adminLinks.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? 'text-white bg-[var(--accent-dim)] border border-[var(--accent)]/30'
: 'text-muted hover:text-white hover:bg-card'
}`
}
>
{({ isActive }) => (
<>
<Icon size={16} className="shrink-0" style={isActive ? { color: 'var(--accent)' } : {}} />
{label}
</>
)}
</NavLink>
))}
</>
)}
</nav>
{/* User info + logout */}
<div className="px-3 py-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2 rounded-lg">
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
style={{ background: isAdmin ? '#8b5cf6' : 'var(--accent)' }}
>
{user?.username?.charAt(0)?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white truncate">{user?.username}</p>
<p className="text-[10px] text-muted capitalize">{user?.role}</p>
</div>
<button
onClick={handleLogout}
title="Sign out"
className="p-1.5 rounded-lg hover:bg-white/5 text-muted hover:text-red-400 transition-colors"
>
<LogOut size={14} />
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,110 @@
import { useNavigate } from 'react-router-dom';
import { ExternalLink, FolderOpen, FileText, Percent } from 'lucide-react';
import type { Project } from '../../types';
import ProgressBar from '../ui/ProgressBar';
import Badge from '../ui/Badge';
interface Props {
project: Project;
}
export default function ProjectCard({ project }: Props) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(`/projects/${project.id}`)}
className="group relative bg-card border border-border rounded-xl p-5 cursor-pointer hover:border-[var(--accent)]/40 hover:shadow-lg hover:shadow-black/20 transition-all duration-200 animate-fade-in"
>
{/* Accent top bar */}
<div
className="absolute top-0 left-0 right-0 h-0.5 rounded-t-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: project.accent_color }}
/>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{project.is_new && <Badge variant="new">New</Badge>}
<Badge variant={project.status}>{project.status}</Badge>
</div>
<h3 className="font-semibold text-white text-base leading-tight truncate">{project.name}</h3>
<p className="text-xs text-muted mt-0.5">{project.category}</p>
</div>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: `${project.accent_color}22`, border: `1px solid ${project.accent_color}44` }}
>
<FolderOpen size={16} style={{ color: project.accent_color }} />
</div>
</div>
{/* Description */}
{project.description && (
<p className="text-sm text-muted/80 line-clamp-2 mb-4">{project.description}</p>
)}
{/* Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted flex items-center gap-1">
<Percent size={10} /> Completion
</span>
<span className="text-xs font-mono text-white">{project.completion}%</span>
</div>
<ProgressBar value={project.completion} color={project.accent_color} />
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted">
{project.doc_count !== undefined && (
<span className="flex items-center gap-1">
<FileText size={11} /> {project.doc_count} doc{project.doc_count !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{project.external_url && (
<a
href={project.external_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded hover:bg-white/10 text-muted hover:text-white transition-colors"
title="Open link"
>
<ExternalLink size={13} />
</a>
)}
{project.drive_url && (
<a
href={project.drive_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded hover:bg-white/10 text-muted hover:text-white transition-colors"
title="Google Drive"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<path d="M4.433 22l3.464-6h12l-3.464 6H4.433zM0 15L6 3.5 9.464 9.5 3.464 20.5 0 15zM14.536 9.5L11.072 3.5H23.072L19.608 9.5H14.536z" />
</svg>
</a>
)}
</div>
</div>
{/* Tags */}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border">
{project.tags.map((tag) => (
<span key={tag} className="text-[11px] px-2 py-0.5 rounded-md bg-white/5 text-muted">
{tag}
</span>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import type { Project } from '../../types';
import Button from '../ui/Button';
const ACCENT_PRESETS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f97316',
'#10b981', '#06b6d4', '#eab308', '#ef4444',
];
const CATEGORIES = ['General', 'AI/ML', 'Automation', 'Data', 'DevOps', 'Frontend', 'Backend', 'Research', 'Other'];
interface Props {
initial?: Partial<Project>;
onSubmit: (data: Partial<Project>) => Promise<void>;
onCancel: () => void;
}
export default function ProjectForm({ initial, onSubmit, onCancel }: Props) {
const [form, setForm] = useState({
name: initial?.name || '',
description: initial?.description || '',
category: initial?.category || 'General',
status: initial?.status || 'active',
completion: initial?.completion ?? 0,
external_url: initial?.external_url || '',
drive_url: initial?.drive_url || '',
tags: initial?.tags?.join(', ') || '',
accent_color: initial?.accent_color || '#6366f1',
is_new: initial?.is_new ?? true,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) { setError('Name is required'); return; }
setLoading(true);
setError('');
try {
await onSubmit({
...form,
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
completion: Number(form.completion),
external_url: form.external_url || null,
drive_url: form.drive_url || null,
});
} catch (err: any) {
setError(err.message || 'Something went wrong');
setLoading(false);
}
};
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Project Name *</label>
<input className={field} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="My AI Project" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Category</label>
<select className={field} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Status</label>
<select className={field} value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as any })}>
<option value="active">Active</option>
<option value="complete">Complete</option>
<option value="archived">Archived</option>
</select>
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Description</label>
<textarea className={`${field} resize-none`} rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Brief overview of the project..." />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Completion {form.completion}%</label>
<input type="range" min={0} max={100} value={form.completion}
onChange={(e) => setForm({ ...form, completion: Number(e.target.value) })}
className="w-full accent-[var(--accent)]"
/>
</div>
<div className="flex items-center gap-3 pt-4">
<input type="checkbox" id="is_new" checked={form.is_new}
onChange={(e) => setForm({ ...form, is_new: e.target.checked })}
className="accent-[var(--accent)] w-4 h-4"
/>
<label htmlFor="is_new" className="text-sm text-muted cursor-pointer">Mark as New</label>
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">External URL</label>
<input className={field} value={form.external_url} onChange={(e) => setForm({ ...form, external_url: e.target.value })} placeholder="https://..." type="url" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Google Drive URL</label>
<input className={field} value={form.drive_url} onChange={(e) => setForm({ ...form, drive_url: e.target.value })} placeholder="https://drive.google.com/..." type="url" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-1.5 block">Tags (comma separated)</label>
<input className={field} value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="python, llm, automation" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted font-medium mb-2 block">Accent Color</label>
<div className="flex items-center gap-2 flex-wrap">
{ACCENT_PRESETS.map((c) => (
<button key={c} type="button" onClick={() => setForm({ ...form, accent_color: c })}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: form.accent_color === c ? 'white' : 'transparent' }}
/>
))}
<input type="color" value={form.accent_color}
onChange={(e) => setForm({ ...form, accent_color: e.target.value })}
className="w-7 h-7 rounded-full cursor-pointer bg-transparent border-0 p-0"
title="Custom color"
/>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={loading} className="flex-1">
{initial?.id ? 'Save Changes' : 'Create Project'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { ExternalLink, Wrench } from 'lucide-react';
import type { Tool } from '../../types';
import Badge from '../ui/Badge';
interface Props {
tool: Tool;
onEdit?: () => void;
onDelete?: () => void;
}
export default function ToolCard({ tool, onEdit, onDelete }: Props) {
return (
<div className="group bg-card border border-border rounded-xl p-5 hover:border-[var(--accent)]/40 transition-all duration-200 animate-fade-in">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{tool.is_new && <Badge variant="new">New</Badge>}
<span className="text-xs text-muted">{tool.category}</span>
</div>
<h3 className="font-semibold text-white text-base leading-tight">{tool.name}</h3>
</div>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-[var(--accent-dim)] border border-[var(--accent)]/20">
<Wrench size={15} style={{ color: 'var(--accent)' }} />
</div>
</div>
{tool.description && (
<p className="text-sm text-muted/80 mb-3 line-clamp-3">{tool.description}</p>
)}
{tool.notes && (
<p className="text-xs text-muted/60 italic mb-3 line-clamp-2">{tool.notes}</p>
)}
<div className="flex items-center gap-2 mt-auto pt-3 border-t border-border">
{tool.external_url && (
<a
href={tool.external_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted hover:text-white transition-colors"
>
<ExternalLink size={12} /> Open Tool
</a>
)}
<div className="flex-1" />
{onEdit && (
<button onClick={onEdit} className="text-xs text-muted hover:text-white transition-colors px-2 py-1 rounded hover:bg-white/5">
Edit
</button>
)}
{onDelete && (
<button onClick={onDelete} className="text-xs text-red-400/60 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-red-500/5">
Delete
</button>
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
interface Props {
children: React.ReactNode;
variant?: 'new' | 'active' | 'complete' | 'archived' | 'default';
}
const styles: Record<string, string> = {
new: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
active: 'bg-blue-500/15 text-blue-400 border border-blue-500/30',
complete: 'bg-violet-500/15 text-violet-400 border border-violet-500/30',
archived: 'bg-zinc-500/15 text-zinc-400 border border-zinc-500/30',
default: 'bg-white/5 text-muted border border-border',
};
export default function Badge({ children, variant = 'default' }: Props) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold uppercase tracking-wide ${styles[variant]}`}>
{children}
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Loader2 } from 'lucide-react';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
loading?: boolean;
}
const base = 'inline-flex items-center gap-2 font-medium rounded-lg transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-[var(--accent)] hover:opacity-90 text-white',
secondary: 'bg-white/5 hover:bg-white/10 text-white border border-border',
ghost: 'hover:bg-white/5 text-muted hover:text-white',
danger: 'bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
};
export default function Button({ variant = 'primary', size = 'md', loading, children, disabled, ...props }: Props) {
return (
<button
{...props}
disabled={disabled || loading}
className={`${base} ${variants[variant]} ${sizes[size]} ${props.className || ''}`}
>
{loading && <Loader2 size={14} className="animate-spin" />}
{children}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
interface Props {
title: string;
onClose: () => void;
children: React.ReactNode;
size?: 'md' | 'lg' | 'xl';
}
export default function Modal({ title, onClose, children, size = 'md' }: Props) {
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
const widths = { md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className={`relative w-full ${widths[size]} bg-card border border-border rounded-2xl shadow-2xl animate-slide-up`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-muted hover:text-white transition-colors">
<X size={18} />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
interface Props {
value: number; // 0100
color?: string;
showLabel?: boolean;
size?: 'sm' | 'md';
}
export default function ProgressBar({ value, color, showLabel = false, size = 'sm' }: Props) {
const pct = Math.min(100, Math.max(0, value));
const h = size === 'sm' ? 'h-1.5' : 'h-2.5';
return (
<div className="flex items-center gap-2 w-full">
<div className={`flex-1 bg-border rounded-full overflow-hidden ${h}`}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, background: color || 'var(--accent)' }}
/>
</div>
{showLabel && (
<span className="text-xs font-mono text-muted tabular-nums w-9 text-right">{pct}%</span>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
export interface AuthUser {
id: string;
username: string;
role: 'admin' | 'user';
}
interface AuthState {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (token: string, user: AuthUser) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthState>({
user: null,
token: null,
loading: true,
login: () => {},
logout: () => {},
});
const TOKEN_KEY = 'codedump_token';
const USER_KEY = 'codedump_user';
export function useAuthProvider(): AuthState {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USER_KEY);
if (storedToken && storedUser) {
try {
// Verify token hasn't expired by checking exp claim
const payload = JSON.parse(atob(storedToken.split('.')[1]));
if (payload.exp * 1000 > Date.now()) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
} catch {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
}
setLoading(false);
}, []);
const login = useCallback((newToken: string, newUser: AuthUser) => {
localStorage.setItem(TOKEN_KEY, newToken);
localStorage.setItem(USER_KEY, JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setToken(null);
setUser(null);
}, []);
return { user, token, loading, login, logout };
}
export const useAuth = () => useContext(AuthContext);
+36
View File
@@ -0,0 +1,36 @@
import { useState, useEffect, createContext, useContext } from 'react';
import type { Settings } from '../types';
import { getSettings } from '../api';
const DEFAULT: Settings = {
app_title: 'AI Tools Dashboard',
logo_url: null,
accent_color: '#6366f1',
company_name: 'Your Company',
};
export const SettingsContext = createContext<{
settings: Settings;
reload: () => void;
}>({ settings: DEFAULT, reload: () => {} });
export function useSettingsProvider() {
const [settings, setSettings] = useState<Settings>(DEFAULT);
const reload = () => {
getSettings().then(setSettings).catch(() => {});
};
useEffect(() => { reload(); }, []);
// Apply accent color as CSS variable
useEffect(() => {
document.documentElement.style.setProperty('--accent', settings.accent_color);
// Generate lighter/darker variants
document.documentElement.style.setProperty('--accent-dim', `${settings.accent_color}33`);
}, [settings.accent_color]);
return { settings, reload };
}
export const useSettings = () => useContext(SettingsContext);
+44
View File
@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--accent: #6366f1;
--accent-dim: #6366f133;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
background-color: #0f0f13;
color: white;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2a3a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3a3a4a; }
select option {
background: #1c1c28;
color: white;
}
input[type="range"] {
cursor: pointer;
}
input[type="color"] {
cursor: pointer;
}
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import './index.css';
import { SettingsContext, useSettingsProvider } from './hooks/useSettings';
import { AuthContext, useAuthProvider } from './hooks/useAuth';
import { useAuth } from './hooks/useAuth';
import Layout from './components/layout/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Tools from './pages/Tools';
import SettingsPage from './pages/Settings';
import AdminUsers from './pages/AdminUsers';
// Guard: must be logged in
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return (
<div className="min-h-screen bg-base flex items-center justify-center">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
}
// Guard: must be admin
function RequireAdmin({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return null;
if (!user || user.role !== 'admin') return <Navigate to="/" replace />;
return <>{children}</>;
}
function App() {
const authCtx = useAuthProvider();
const settingsCtx = useSettingsProvider();
return (
<AuthContext.Provider value={authCtx}>
<SettingsContext.Provider value={settingsCtx}>
<BrowserRouter>
<Routes>
{/* Public */}
<Route path="/login" element={<Login />} />
{/* Protected */}
<Route path="/" element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route index element={<Dashboard />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="tools" element={<Tools />} />
{/* Admin-only routes */}
<Route path="settings" element={
<RequireAdmin><SettingsPage /></RequireAdmin>
} />
<Route path="admin/users" element={
<RequireAdmin><AdminUsers /></RequireAdmin>
} />
</Route>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</SettingsContext.Provider>
</AuthContext.Provider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+315
View File
@@ -0,0 +1,315 @@
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Shield, User as UserIcon, Key, RefreshCw } from 'lucide-react';
import type { User } from '../types';
import { getUsers, createUser, updateUser, deleteUser } from '../api';
import Modal from '../components/ui/Modal';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
// ─── PIN input component ──────────────────────────────────────────────────────
function PinInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4);
onChange(v);
};
return (
<div className="relative">
<input
type="text"
inputMode="numeric"
pattern="\d{4}"
maxLength={4}
value={value}
onChange={handleChange}
placeholder="0000"
className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted font-mono tracking-[0.5em] text-center focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex gap-1">
{[0,1,2,3].map((i) => (
<div key={i} className={`w-2 h-2 rounded-full transition-all ${i < value.length ? 'scale-110' : 'bg-border'}`}
style={i < value.length ? { background: 'var(--accent)' } : {}} />
))}
</div>
</div>
);
}
// ─── Create / Edit form ───────────────────────────────────────────────────────
function UserForm({ initial, onSubmit, onCancel }: {
initial?: User;
onSubmit: (d: any) => Promise<void>;
onCancel: () => void;
}) {
const isEdit = Boolean(initial);
const [username, setUsername] = useState(initial?.username || '');
const [role, setRole] = useState<'user' | 'admin'>(initial?.role || 'user');
const [pin, setPin] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!isEdit && !username.trim()) { setError('Username is required'); return; }
if (role === 'user' && !isEdit && (pin.length !== 4)) {
setError('A 4-digit PIN is required'); return;
}
if (role === 'user' && isEdit && pin && pin.length !== 4) {
setError('PIN must be exactly 4 digits'); return;
}
if (role === 'admin' && !isEdit && password.length < 6) {
setError('Password must be at least 6 characters'); return;
}
setLoading(true);
try {
const payload: any = {};
if (!isEdit) {
payload.username = username.trim();
payload.role = role;
}
if (isEdit && username.trim() !== initial?.username) payload.username = username.trim();
if (role === 'user' && pin) payload.pin = pin;
if (role === 'admin' && password) payload.password = password;
await onSubmit(payload);
} catch (err: any) {
setError(err.message || 'Something went wrong');
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>
)}
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Username</label>
<input className={field} value={username} onChange={(e) => setUsername(e.target.value)}
placeholder="e.g. jsmith" disabled={false} />
</div>
{!isEdit && (
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Role</label>
<div className="flex gap-2">
{(['user', 'admin'] as const).map((r) => (
<button key={r} type="button"
onClick={() => { setRole(r); setPin(''); setPassword(''); }}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-all ${
role === r
? 'border-[var(--accent)] bg-[var(--accent-dim)] text-white'
: 'border-border text-muted hover:text-white hover:border-white/20'
}`}
>
{r === 'admin' ? <Shield size={14} /> : <UserIcon size={14} />}
{r.charAt(0).toUpperCase() + r.slice(1)}
</button>
))}
</div>
</div>
)}
{(role === 'user' || initial?.role === 'user') && (
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">
{isEdit ? 'New PIN (leave blank to keep current)' : '4-Digit PIN *'}
</label>
<PinInput value={pin} onChange={setPin} />
<p className="text-xs text-muted/60 mt-1">Numbers only · exactly 4 digits</p>
</div>
)}
{(role === 'admin' || initial?.role === 'admin') && (
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">
{isEdit ? 'New Password (leave blank to keep current)' : 'Password *'}
</label>
<input type="password" className={field} value={password} onChange={(e) => setPassword(e.target.value)}
placeholder={isEdit ? '••••••••' : 'Min 6 characters'} autoComplete="new-password" />
</div>
)}
<div className="flex gap-3 pt-2">
<Button type="submit" loading={loading} className="flex-1">
{isEdit ? 'Save Changes' : 'Create User'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function AdminUsers() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState('');
const load = () => getUsers().then((u) => setUsers(u as User[])).finally(() => setLoading(false));
useEffect(() => { load(); }, []);
const handleCreate = async (data: any) => {
await createUser(data);
setShowCreate(false);
load();
};
const handleEdit = async (data: any) => {
await updateUser(editUser!.id, data);
setEditUser(null);
load();
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError('');
try {
await deleteUser(deleteTarget.id);
setDeleteTarget(null);
load();
} catch (err: any) {
setDeleteError(err.message || 'Failed to delete user');
} finally {
setDeleting(false);
}
};
const admins = users.filter((u) => u.role === 'admin');
const regularUsers = users.filter((u) => u.role === 'user');
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
const UserRow = ({ user }: { user: User }) => (
<div className="flex items-center gap-4 p-4 rounded-xl border border-border bg-card hover:border-[var(--accent)]/30 transition-all animate-fade-in">
<div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shrink-0"
style={{ background: user.role === 'admin' ? '#8b5cf6' : 'var(--accent)' }}>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-white text-sm">{user.username}</span>
{user.role === 'admin'
? <Badge variant="complete">Admin</Badge>
: <Badge variant="active">User</Badge>}
</div>
<p className="text-xs text-muted mt-0.5">
{user.role === 'user' ? <span className="flex items-center gap-1"><Key size={10} /> PIN login</span> : <span className="flex items-center gap-1"><Shield size={10} /> Password login</span>}
{' · '}Added {new Date(user.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="ghost" size="sm" onClick={() => setEditUser(user)}>
<RefreshCw size={13} /> Reset {user.role === 'user' ? 'PIN' : 'Password'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditUser(user)}>
<Pencil size={13} />
</Button>
<Button variant="danger" size="sm" onClick={() => { setDeleteTarget(user); setDeleteError(''); }}>
<Trash2 size={13} />
</Button>
</div>
</div>
);
return (
<div className="p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-2">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-muted text-sm mt-0.5">{users.length} account{users.length !== 1 ? 's' : ''}</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus size={15} /> New User
</Button>
</div>
{/* Info box */}
<div className="bg-[var(--accent-dim)] border border-[var(--accent)]/20 rounded-xl p-4 mb-8 mt-4">
<p className="text-xs text-white/70 leading-relaxed">
<strong className="text-white">Users</strong> log in with a 4-digit PIN from the main login screen and can update projects, upload docs, and manage tools.{' '}
<strong className="text-white">Admins</strong> log in with username &amp; password and also have access to Settings and User Management.
</p>
</div>
{/* Admin accounts */}
<section className="mb-6">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
<Shield size={13} /> Admin Accounts ({admins.length})
</h2>
<div className="space-y-2">
{admins.map((u) => <UserRow key={u.id} user={u} />)}
</div>
</section>
{/* Regular users */}
<section>
<h2 className="text-sm font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
<UserIcon size={13} /> User Accounts ({regularUsers.length})
</h2>
{regularUsers.length === 0 ? (
<div className="text-center py-10 border border-dashed border-border rounded-xl">
<UserIcon size={28} className="mx-auto mb-2 text-muted/30" />
<p className="text-muted text-sm">No user accounts yet.</p>
<button onClick={() => setShowCreate(true)} className="text-xs mt-1 transition-colors" style={{ color: 'var(--accent)' }}>
Create the first user
</button>
</div>
) : (
<div className="space-y-2">
{regularUsers.map((u) => <UserRow key={u.id} user={u} />)}
</div>
)}
</section>
{/* Modals */}
{showCreate && (
<Modal title="Create User" onClose={() => setShowCreate(false)}>
<UserForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
</Modal>
)}
{editUser && (
<Modal title={`Edit — ${editUser.username}`} onClose={() => setEditUser(null)}>
<UserForm initial={editUser} onSubmit={handleEdit} onCancel={() => setEditUser(null)} />
</Modal>
)}
{deleteTarget && (
<Modal title="Delete User" onClose={() => setDeleteTarget(null)}>
<p className="text-muted mb-2">
Permanently delete <strong className="text-white">{deleteTarget.username}</strong>?
</p>
<p className="text-xs text-muted/60 mb-6">This cannot be undone.</p>
{deleteError && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4">{deleteError}</p>
)}
<div className="flex gap-3">
<Button variant="danger" loading={deleting} onClick={handleDelete} className="flex-1">
Delete User
</Button>
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>Cancel</Button>
</div>
</Modal>
)}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { FolderKanban, Wrench, TrendingUp, Sparkles, ArrowRight, CheckCircle2 } from 'lucide-react';
import type { Project, Tool } from '../types';
import { getProjects, getTools } from '../api';
import ProjectCard from '../components/projects/ProjectCard';
import ToolCard from '../components/tools/ToolCard';
import ProgressBar from '../components/ui/ProgressBar';
import { useSettings } from '../hooks/useSettings';
function StatCard({ label, value, icon: Icon, sub }: { label: string; value: string | number; icon: any; sub?: string }) {
return (
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<p className="text-sm text-muted font-medium">{label}</p>
<div className="w-8 h-8 rounded-lg bg-[var(--accent-dim)] border border-[var(--accent)]/20 flex items-center justify-center">
<Icon size={15} style={{ color: 'var(--accent)' }} />
</div>
</div>
<p className="text-3xl font-bold text-white tabular-nums">{value}</p>
{sub && <p className="text-xs text-muted mt-1">{sub}</p>}
</div>
);
}
export default function Dashboard() {
const { settings } = useSettings();
const [projects, setProjects] = useState<Project[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([getProjects(), getTools()])
.then(([p, t]) => { setProjects(p); setTools(t); })
.finally(() => setLoading(false));
}, []);
const active = projects.filter((p) => p.status === 'active');
const complete = projects.filter((p) => p.status === 'complete');
const newTools = tools.filter((t) => t.is_new);
const avgCompletion = projects.length
? Math.round(projects.reduce((s, p) => s + p.completion, 0) / projects.length)
: 0;
const recent = [...projects].slice(0, 6);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-1">{settings.app_title}</h1>
<p className="text-muted text-sm">High-level overview of tools and projects.</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Total Projects" value={projects.length} icon={FolderKanban} sub={`${active.length} active`} />
<StatCard label="Completed" value={complete.length} icon={CheckCircle2} sub="finished projects" />
<StatCard label="Avg Completion" value={`${avgCompletion}%`} icon={TrendingUp} sub="across all projects" />
<StatCard label="Available Tools" value={tools.length} icon={Wrench} sub={`${newTools.length} new`} />
</div>
{/* Overall progress */}
{projects.length > 0 && (
<div className="bg-card border border-border rounded-xl p-5 mb-8">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-white">Overall Portfolio Progress</p>
<span className="text-sm font-mono text-white">{avgCompletion}%</span>
</div>
<ProgressBar value={avgCompletion} size="md" />
</div>
)}
{/* New Tools Spotlight */}
{newTools.length > 0 && (
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
<h2 className="text-base font-semibold text-white">New Tools Available</h2>
</div>
<Link to="/tools" className="text-xs text-muted hover:text-white flex items-center gap-1 transition-colors">
View all <ArrowRight size={12} />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{newTools.slice(0, 3).map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
</section>
)}
{/* Recent Projects */}
<section>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FolderKanban size={16} style={{ color: 'var(--accent)' }} />
<h2 className="text-base font-semibold text-white">Recent Projects</h2>
</div>
<Link to="/projects" className="text-xs text-muted hover:text-white flex items-center gap-1 transition-colors">
View all <ArrowRight size={12} />
</Link>
</div>
{recent.length === 0 ? (
<div className="bg-card border border-border rounded-xl p-12 text-center">
<FolderKanban size={32} className="mx-auto mb-3 text-muted/40" />
<p className="text-muted text-sm">No projects yet.</p>
<Link to="/projects" className="text-xs mt-2 inline-block" style={{ color: 'var(--accent)' }}>
Create your first project
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</section>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Delete, ChevronLeft, Lock, Shield } from 'lucide-react';
import { pinLogin, adminLogin } from '../api';
import { useAuth } from '../hooks/useAuth';
import { useSettings } from '../hooks/useSettings';
type LoginMode = 'pin' | 'admin';
const PAD_KEYS = ['1','2','3','4','5','6','7','8','9','','0','⌫'];
export default function Login() {
const navigate = useNavigate();
const { login, user } = useAuth();
const { settings } = useSettings();
const [mode, setMode] = useState<LoginMode>('pin');
const [pin, setPin] = useState('');
const [adminUsername, setAdminUsername] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [shake, setShake] = useState(false);
useEffect(() => {
if (user) navigate('/', { replace: true });
}, [user, navigate]);
// Auto-submit when 4 digits entered
useEffect(() => {
if (pin.length === 4) submitPin();
}, [pin]);
const triggerShake = () => {
setShake(true);
setTimeout(() => setShake(false), 600);
};
const handleKeyPress = (key: string) => {
setError('');
if (key === '⌫') { setPin((p) => p.slice(0, -1)); return; }
if (pin.length < 4) setPin((p) => p + key);
};
const submitPin = async () => {
if (pin.length !== 4) return;
setLoading(true);
try {
const { token, user: u } = await pinLogin(pin);
login(token, u);
navigate('/', { replace: true });
} catch {
setError('Invalid PIN');
setPin('');
triggerShake();
} finally {
setLoading(false);
}
};
const handleAdminSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!adminUsername || !adminPassword) return;
setLoading(true);
setError('');
try {
const { token, user: u } = await adminLogin(adminUsername, adminPassword);
login(token, u);
navigate('/', { replace: true });
} catch {
setError('Invalid username or password');
triggerShake();
} finally {
setLoading(false);
}
};
const switchMode = (m: LoginMode) => {
setMode(m);
setPin('');
setError('');
};
return (
<div className="min-h-screen bg-base flex flex-col items-center justify-center p-4">
{/* Brand */}
<div className="text-center mb-10">
{settings.logo_url ? (
<img src={settings.logo_url} alt="Logo" className="h-14 mx-auto mb-4 object-contain" />
) : (
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-2xl font-black mx-auto mb-4"
style={{ background: 'var(--accent)' }}
>
{settings.company_name?.charAt(0)?.toUpperCase() || 'C'}
</div>
)}
<h1 className="text-3xl font-black tracking-tight text-white">CODEDUMP</h1>
<p className="text-muted text-sm mt-1">{settings.company_name}</p>
</div>
{/* Card */}
<div
className="w-full max-w-xs bg-card border border-border rounded-2xl shadow-2xl overflow-hidden"
style={{ animation: shake ? 'shake 0.5s ease-in-out' : undefined }}
>
{/* ── PIN pad ───────────────────────────────────────────── */}
{mode === 'pin' && (
<div className="p-6">
<div className="text-center mb-7">
<p className="text-base font-semibold text-white">Enter your PIN</p>
<p className="text-xs text-muted mt-1">4-digit PIN to sign in</p>
</div>
{/* Dot indicator */}
<div className="flex items-center justify-center gap-5 mb-3">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="w-3.5 h-3.5 rounded-full border-2 transition-all duration-150"
style={
i < pin.length
? { background: 'var(--accent)', borderColor: 'var(--accent)', transform: 'scale(1.15)' }
: { borderColor: '#2a2a3a' }
}
/>
))}
</div>
{/* Error */}
<div className="h-5 flex items-center justify-center mb-3">
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
{/* Keypad */}
<div className="grid grid-cols-3 gap-2.5">
{PAD_KEYS.map((key, idx) => (
<button
key={idx}
onClick={() => key && handleKeyPress(key)}
disabled={loading || !key}
className={`h-14 rounded-xl text-lg font-semibold select-none transition-all duration-100
${key === ''
? 'cursor-default'
: key === '⌫'
? 'bg-white/5 hover:bg-white/10 text-muted hover:text-white active:scale-95'
: 'bg-surface hover:bg-white/10 border border-border hover:border-[var(--accent)]/40 text-white active:scale-95 active:bg-[var(--accent-dim)]'
}`}
>
{key === '⌫' ? <Delete size={18} className="mx-auto" /> : key}
</button>
))}
</div>
{loading && (
<div className="flex justify-center mt-5">
<div className="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
</div>
)}
{/* Admin link */}
<div className="mt-6 text-center">
<button
onClick={() => switchMode('admin')}
className="flex items-center gap-1.5 text-xs text-muted/50 hover:text-muted transition-colors mx-auto"
>
<Shield size={11} /> Admin Login
</button>
</div>
</div>
)}
{/* ── Admin login ───────────────────────────────────────── */}
{mode === 'admin' && (
<form onSubmit={handleAdminSubmit} className="p-6">
<button
type="button"
onClick={() => switchMode('pin')}
className="flex items-center gap-1.5 text-xs text-muted hover:text-white transition-colors mb-5"
>
<ChevronLeft size={14} /> Back
</button>
<div className="text-center mb-6">
<div className="w-12 h-12 rounded-full bg-[var(--accent-dim)] border border-[var(--accent)]/30 flex items-center justify-center mx-auto mb-3">
<Lock size={20} style={{ color: 'var(--accent)' }} />
</div>
<p className="text-sm font-semibold text-white">Admin Login</p>
</div>
{error && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4 text-center text-xs">{error}</p>
)}
<div className="space-y-3">
<input
type="text"
autoComplete="username"
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
placeholder="Username"
className="w-full bg-surface border border-border rounded-lg px-3 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
<input
type="password"
autoComplete="current-password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
placeholder="Password"
className="w-full bg-surface border border-border rounded-lg px-3 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<button
type="submit"
disabled={loading || !adminUsername || !adminPassword}
className="w-full mt-4 py-2.5 rounded-lg text-sm font-semibold text-white transition-all disabled:opacity-50"
style={{ background: 'var(--accent)' }}
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
)}
</div>
<p className="text-xs text-muted/30 mt-6">CODEDUMP · Internal Tool</p>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-8px); }
30% { transform: translateX(8px); }
45% { transform: translateX(-6px); }
60% { transform: translateX(6px); }
75% { transform: translateX(-4px); }
90% { transform: translateX(4px); }
}
`}</style>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
ArrowLeft, ExternalLink, Pencil, Trash2, Upload, FileText,
X, Save, FolderOpen, Eye, Code2
} from 'lucide-react';
import type { Project, Document } from '../types';
import { getProject, updateProject, deleteProject, uploadDocument, deleteDocument, getFileUrl } from '../api';
import ProgressBar from '../components/ui/ProgressBar';
import Badge from '../components/ui/Badge';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ProjectForm from '../components/projects/ProjectForm';
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const fileRef = useRef<HTMLInputElement>(null);
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [showEdit, setShowEdit] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeDoc, setActiveDoc] = useState<Document | null>(null);
const [docContent, setDocContent] = useState<string>('');
const [docViewMode, setDocViewMode] = useState<'preview' | 'raw'>('preview');
const [uploading, setUploading] = useState(false);
const [completion, setCompletion] = useState(0);
const [savingCompletion, setSavingCompletion] = useState(false);
const load = () => {
if (!id) return;
getProject(id)
.then((p) => { setProject(p); setCompletion(p.completion); })
.catch(() => navigate('/projects'))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [id]);
const fetchDocContent = async (doc: Document) => {
setActiveDoc(doc);
setDocContent('');
const res = await fetch(getFileUrl(doc.filename));
const text = await res.text();
setDocContent(text);
};
const handleEdit = async (data: Partial<Project>) => {
await updateProject(id!, data);
setShowEdit(false);
load();
};
const handleDelete = async () => {
await deleteProject(id!);
navigate('/projects');
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !id) return;
setUploading(true);
try {
await uploadDocument(id, file);
load();
} finally {
setUploading(false);
e.target.value = '';
}
};
const handleDeleteDoc = async (doc: Document) => {
await deleteDocument(doc.id);
if (activeDoc?.id === doc.id) { setActiveDoc(null); setDocContent(''); }
load();
};
const handleSaveCompletion = async () => {
setSavingCompletion(true);
await updateProject(id!, { completion });
setSavingCompletion(false);
load();
};
if (loading || !project) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
const isMarkdown = (doc: Document) =>
doc.original_name.endsWith('.md') || doc.original_name.endsWith('.txt') || doc.mimetype.startsWith('text/');
return (
<div className="p-8 max-w-7xl mx-auto">
{/* Breadcrumb */}
<Link to="/projects" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-white transition-colors mb-6">
<ArrowLeft size={14} /> Projects
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
{project.is_new && <Badge variant="new">New</Badge>}
<Badge variant={project.status}>{project.status}</Badge>
<span className="text-xs text-muted">{project.category}</span>
</div>
<h1 className="text-2xl font-bold text-white mb-2">{project.name}</h1>
{project.description && <p className="text-muted leading-relaxed">{project.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="secondary" size="sm" onClick={() => setShowEdit(true)}>
<Pencil size={13} /> Edit
</Button>
<Button variant="danger" size="sm" onClick={() => setShowDeleteConfirm(true)}>
<Trash2 size={13} /> Delete
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column — metadata */}
<div className="space-y-4">
{/* Completion editor */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-medium text-white mb-3">Completion</p>
<div className="flex items-center justify-between mb-2">
<span className="text-3xl font-bold text-white tabular-nums">{completion}%</span>
</div>
<ProgressBar value={completion} color={project.accent_color} size="md" />
<input
type="range" min={0} max={100} value={completion}
onChange={(e) => setCompletion(Number(e.target.value))}
className="w-full mt-3 accent-[var(--accent)]"
/>
{completion !== project.completion && (
<Button size="sm" className="w-full mt-2" loading={savingCompletion} onClick={handleSaveCompletion}>
<Save size={12} /> Save
</Button>
)}
</div>
{/* Links */}
{(project.external_url || project.drive_url) && (
<div className="bg-card border border-border rounded-xl p-5 space-y-3">
<p className="text-sm font-medium text-white">Links</p>
{project.external_url && (
<a href={project.external_url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted hover:text-white transition-colors">
<ExternalLink size={13} style={{ color: 'var(--accent)' }} />
{project.external_url.length > 38 ? project.external_url.slice(0, 35) + '…' : project.external_url}
</a>
)}
{project.drive_url && (
<a href={project.drive_url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted hover:text-white transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--accent)' }}>
<path d="M4.433 22l3.464-6h12l-3.464 6H4.433zM0 15L6 3.5 9.464 9.5 3.464 20.5 0 15zM14.536 9.5L11.072 3.5H23.072L19.608 9.5H14.536z" />
</svg>
Google Drive
</a>
)}
</div>
)}
{/* Tags */}
{project.tags.length > 0 && (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-medium text-white mb-3">Tags</p>
<div className="flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span key={tag} className="text-xs px-2.5 py-1 rounded-full bg-white/5 text-muted border border-border">
{tag}
</span>
))}
</div>
</div>
)}
{/* Documents list */}
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-white">Documents</p>
<button onClick={() => fileRef.current?.click()}
className="flex items-center gap-1.5 text-xs text-muted hover:text-white transition-colors"
disabled={uploading}
>
<Upload size={12} /> {uploading ? 'Uploading…' : 'Upload'}
</button>
<input ref={fileRef} type="file" className="hidden"
accept=".md,.txt,.pdf,.png,.jpg,.jpeg,.gif,.svg"
onChange={handleUpload}
/>
</div>
{project.documents?.length === 0 ? (
<p className="text-xs text-muted/60">No documents yet.</p>
) : (
<ul className="space-y-1">
{project.documents?.map((doc) => (
<li key={doc.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors group ${
activeDoc?.id === doc.id ? 'bg-[var(--accent-dim)]' : 'hover:bg-white/5'
}`}
onClick={() => isMarkdown(doc) || doc.mimetype.startsWith('text/') ? fetchDocContent(doc) : window.open(getFileUrl(doc.filename), '_blank')}
>
<FileText size={12} className="text-muted shrink-0" />
<span className="text-xs text-white truncate flex-1">{doc.original_name}</span>
<button onClick={(e) => { e.stopPropagation(); handleDeleteDoc(doc); }}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-red-400 transition-all">
<X size={11} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
{/* Right column — doc viewer */}
<div className="lg:col-span-2">
{activeDoc ? (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-medium text-white">{activeDoc.original_name}</span>
<div className="flex items-center gap-1">
<button onClick={() => setDocViewMode('preview')}
className={`p-1.5 rounded transition-colors ${docViewMode === 'preview' ? 'bg-[var(--accent-dim)] text-white' : 'text-muted hover:text-white'}`}>
<Eye size={14} />
</button>
<button onClick={() => setDocViewMode('raw')}
className={`p-1.5 rounded transition-colors ${docViewMode === 'raw' ? 'bg-[var(--accent-dim)] text-white' : 'text-muted hover:text-white'}`}>
<Code2 size={14} />
</button>
<button onClick={() => setActiveDoc(null)} className="p-1.5 rounded text-muted hover:text-white transition-colors ml-1">
<X size={14} />
</button>
</div>
</div>
<div className="p-6 overflow-auto max-h-[70vh]">
{docViewMode === 'preview' ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-zinc-300 prose-p:leading-relaxed
prose-a:text-[var(--accent)] prose-a:no-underline hover:prose-a:underline
prose-code:bg-white/10 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono
prose-pre:bg-surface prose-pre:border prose-pre:border-border
prose-blockquote:border-l-[var(--accent)] prose-blockquote:text-muted
prose-li:text-zinc-300 prose-strong:text-white
prose-table:text-sm prose-th:text-muted prose-td:text-zinc-300
prose-hr:border-border">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{docContent}</ReactMarkdown>
</div>
) : (
<pre className="text-xs text-zinc-300 font-mono whitespace-pre-wrap">{docContent}</pre>
)}
</div>
</div>
) : (
<div className="bg-card border border-dashed border-border rounded-xl p-16 text-center h-full flex flex-col items-center justify-center">
<FolderOpen size={40} className="text-muted/30 mb-4" />
<p className="text-muted text-sm">Select a document to preview it here</p>
<button onClick={() => fileRef.current?.click()}
className="mt-3 text-xs transition-colors" style={{ color: 'var(--accent)' }}>
or upload a new file
</button>
</div>
)}
</div>
</div>
{/* Edit modal */}
{showEdit && (
<Modal title="Edit Project" onClose={() => setShowEdit(false)} size="lg">
<ProjectForm initial={project} onSubmit={handleEdit} onCancel={() => setShowEdit(false)} />
</Modal>
)}
{/* Delete confirm */}
{showDeleteConfirm && (
<Modal title="Delete Project" onClose={() => setShowDeleteConfirm(false)}>
<p className="text-muted mb-6">This will permanently delete <strong className="text-white">{project.name}</strong> and all its documents. This cannot be undone.</p>
<div className="flex gap-3">
<Button variant="danger" onClick={handleDelete} className="flex-1">Delete Project</Button>
<Button variant="secondary" onClick={() => setShowDeleteConfirm(false)}>Cancel</Button>
</div>
</Modal>
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Search, Filter } from 'lucide-react';
import type { Project } from '../types';
import { getProjects, createProject, updateProject, deleteProject } from '../api';
import ProjectCard from '../components/projects/ProjectCard';
import ProjectForm from '../components/projects/ProjectForm';
import Modal from '../components/ui/Modal';
import Button from '../components/ui/Button';
const STATUS_OPTS = ['all', 'active', 'complete', 'archived'] as const;
export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const load = () => getProjects().then(setProjects).finally(() => setLoading(false));
useEffect(() => { load(); }, []);
const categories = useMemo(() => {
const cats = new Set(projects.map((p) => p.category));
return ['all', ...Array.from(cats).sort()];
}, [projects]);
const filtered = useMemo(() => projects.filter((p) => {
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase()) ||
p.tags.some((t) => t.toLowerCase().includes(search.toLowerCase()));
const matchStatus = statusFilter === 'all' || p.status === statusFilter;
const matchCat = categoryFilter === 'all' || p.category === categoryFilter;
return matchSearch && matchStatus && matchCat;
}), [projects, search, statusFilter, categoryFilter]);
const handleCreate = async (data: Partial<Project>) => {
await createProject(data);
setShowCreate(false);
load();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Projects</h1>
<p className="text-muted text-sm mt-0.5">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus size={15} /> New Project
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative flex-1 min-w-48">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects..."
className="w-full bg-card border border-border rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<div className="flex items-center gap-2">
<Filter size={13} className="text-muted shrink-0" />
<div className="flex bg-card border border-border rounded-lg overflow-hidden">
{STATUS_OPTS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-2 text-xs font-medium capitalize transition-colors ${
statusFilter === s ? 'bg-[var(--accent)] text-white' : 'text-muted hover:text-white'
}`}
>
{s}
</button>
))}
</div>
</div>
{categories.length > 2 && (
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-card border border-border rounded-lg px-3 py-2 text-sm text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
>
{categories.map((c) => <option key={c} value={c}>{c === 'all' ? 'All Categories' : c}</option>)}
</select>
)}
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="text-muted">
{projects.length === 0 ? 'No projects yet. Create your first one.' : 'No projects match your filters.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((p) => (
<ProjectCard key={p.id} project={p} />
))}
</div>
)}
{/* Create modal */}
{showCreate && (
<Modal title="New Project" onClose={() => setShowCreate(false)} size="lg">
<ProjectForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
</Modal>
)}
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { useState, useEffect, useRef } from 'react';
import { Upload, Check, Palette } from 'lucide-react';
import type { Settings } from '../types';
import { getSettings, updateSettings, uploadLogo } from '../api';
import Button from '../components/ui/Button';
import { useSettings } from '../hooks/useSettings';
const ACCENT_PRESETS = [
{ label: 'Indigo', value: '#6366f1' },
{ label: 'Violet', value: '#8b5cf6' },
{ label: 'Pink', value: '#ec4899' },
{ label: 'Orange', value: '#f97316' },
{ label: 'Emerald', value: '#10b981' },
{ label: 'Cyan', value: '#06b6d4' },
{ label: 'Yellow', value: '#eab308' },
{ label: 'Red', value: '#ef4444' },
];
export default function SettingsPage() {
const { reload } = useSettings();
const logoRef = useRef<HTMLInputElement>(null);
const [settings, setSettings] = useState<Settings>({ app_title: '', logo_url: null, accent_color: '#6366f1', company_name: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
getSettings().then(setSettings).finally(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
await updateSettings(settings);
reload();
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
try {
const { logo_url } = await uploadLogo(file);
setSettings((s) => ({ ...s, logo_url }));
reload();
} finally {
setUploadingLogo(false);
e.target.value = '';
}
};
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2.5 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-white mb-1">Settings</h1>
<p className="text-muted text-sm mb-8">Customize your dashboard branding and appearance.</p>
<form onSubmit={handleSave} className="space-y-8">
{/* Branding */}
<section>
<h2 className="text-base font-semibold text-white mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 rounded-sm inline-block" style={{ background: 'var(--accent)' }} />
Branding
</h2>
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
{/* Logo */}
<div>
<label className="text-xs text-muted font-medium mb-2 block">Company Logo</label>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl border border-border bg-surface flex items-center justify-center overflow-hidden">
{settings.logo_url ? (
<img src={settings.logo_url} alt="Logo" className="w-full h-full object-contain p-1" />
) : (
<span className="text-2xl font-bold" style={{ color: 'var(--accent)' }}>
{settings.company_name?.charAt(0)?.toUpperCase() || '?'}
</span>
)}
</div>
<div className="space-y-2">
<Button type="button" variant="secondary" size="sm" loading={uploadingLogo} onClick={() => logoRef.current?.click()}>
<Upload size={13} /> Upload Logo
</Button>
<p className="text-xs text-muted">PNG, JPG, SVG · max 5 MB</p>
<input ref={logoRef} type="file" className="hidden" accept=".png,.jpg,.jpeg,.svg,.gif,.webp" onChange={handleLogoUpload} />
</div>
</div>
<div className="mt-3">
<label className="text-xs text-muted font-medium mb-1.5 block">Or paste a logo URL</label>
<input className={field} value={settings.logo_url || ''} onChange={(e) => setSettings({ ...settings, logo_url: e.target.value || null })} placeholder="https://..." type="url" />
</div>
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">App Title</label>
<input className={field} value={settings.app_title} onChange={(e) => setSettings({ ...settings, app_title: e.target.value })} placeholder="AI Tools Dashboard" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Company Name</label>
<input className={field} value={settings.company_name} onChange={(e) => setSettings({ ...settings, company_name: e.target.value })} placeholder="Your Company" />
</div>
</div>
</section>
{/* Theme */}
<section>
<h2 className="text-base font-semibold text-white mb-4 flex items-center gap-2">
<Palette size={16} style={{ color: 'var(--accent)' }} />
Theme
</h2>
<div className="bg-card border border-border rounded-xl p-6">
<label className="text-xs text-muted font-medium mb-3 block">Accent Color</label>
<div className="flex flex-wrap gap-3 mb-4">
{ACCENT_PRESETS.map(({ label, value }) => (
<button
key={value}
type="button"
onClick={() => setSettings({ ...settings, accent_color: value })}
title={label}
className="relative w-9 h-9 rounded-full border-2 transition-all"
style={{ background: value, borderColor: settings.accent_color === value ? 'white' : 'transparent' }}
>
{settings.accent_color === value && (
<Check size={14} className="absolute inset-0 m-auto text-white" />
)}
</button>
))}
<div className="relative">
<input
type="color"
value={settings.accent_color}
onChange={(e) => setSettings({ ...settings, accent_color: e.target.value })}
className="w-9 h-9 rounded-full cursor-pointer border-2 border-border p-0.5 bg-transparent"
title="Custom color"
/>
</div>
</div>
{/* Preview */}
<div className="mt-4 p-4 bg-surface border border-border rounded-lg">
<p className="text-xs text-muted mb-3">Preview</p>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 bg-border rounded-full overflow-hidden">
<div className="h-full w-2/3 rounded-full transition-all" style={{ background: settings.accent_color }} />
</div>
<span className="text-xs font-mono text-white">67%</span>
</div>
<div className="flex gap-2 mt-3">
<span className="text-[11px] px-2 py-0.5 rounded font-semibold" style={{ background: `${settings.accent_color}22`, color: settings.accent_color, border: `1px solid ${settings.accent_color}44` }}>
NEW
</span>
<button className="text-xs px-3 py-1 rounded-lg text-white" style={{ background: settings.accent_color }}>
Action
</button>
</div>
</div>
</div>
</section>
<Button type="submit" loading={saving} className="w-full">
{saved ? <><Check size={15} /> Saved!</> : 'Save Settings'}
</Button>
</form>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Search, Sparkles } from 'lucide-react';
import type { Tool } from '../types';
import { getTools, createTool, updateTool, deleteTool } from '../api';
import ToolCard from '../components/tools/ToolCard';
import Modal from '../components/ui/Modal';
import Button from '../components/ui/Button';
const CATEGORIES = ['General', 'AI/ML', 'Automation', 'Data', 'DevOps', 'Frontend', 'Backend', 'Research', 'Other'];
function ToolForm({ initial, onSubmit, onCancel }: {
initial?: Partial<Tool>;
onSubmit: (d: Partial<Tool>) => Promise<void>;
onCancel: () => void;
}) {
const [form, setForm] = useState({
name: initial?.name || '',
description: initial?.description || '',
category: initial?.category || 'General',
external_url: initial?.external_url || '',
is_new: initial?.is_new ?? true,
notes: initial?.notes || '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) { setError('Name is required'); return; }
setLoading(true); setError('');
try {
await onSubmit({ ...form, external_url: form.external_url || null });
} catch (err: any) {
setError(err.message || 'Something went wrong');
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>}
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Tool Name *</label>
<input className={field} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Claude API" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Category</label>
<select className={field} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Description</label>
<textarea className={`${field} resize-none`} rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this tool do?" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">External URL</label>
<input className={field} value={form.external_url} onChange={(e) => setForm({ ...form, external_url: e.target.value })} placeholder="https://..." type="url" />
</div>
<div>
<label className="text-xs text-muted font-medium mb-1.5 block">Notes</label>
<textarea className={`${field} resize-none`} rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Usage notes, tips, caveats..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="tool_is_new" checked={form.is_new} onChange={(e) => setForm({ ...form, is_new: e.target.checked })} className="accent-[var(--accent)] w-4 h-4" />
<label htmlFor="tool_is_new" className="text-sm text-muted cursor-pointer">Mark as New</label>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={loading} className="flex-1">
{initial?.id ? 'Save Changes' : 'Add Tool'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
export default function Tools() {
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editTool, setEditTool] = useState<Tool | null>(null);
const [search, setSearch] = useState('');
const [catFilter, setCatFilter] = useState('all');
const [newOnly, setNewOnly] = useState(false);
const load = () => getTools().then(setTools).finally(() => setLoading(false));
useEffect(() => { load(); }, []);
const cats = useMemo(() => {
const s = new Set(tools.map((t) => t.category));
return ['all', ...Array.from(s).sort()];
}, [tools]);
const filtered = useMemo(() => tools.filter((t) => {
const matchSearch = !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.description.toLowerCase().includes(search.toLowerCase());
const matchCat = catFilter === 'all' || t.category === catFilter;
return matchSearch && matchCat && (!newOnly || t.is_new);
}), [tools, search, catFilter, newOnly]);
const newCount = tools.filter((t) => t.is_new).length;
const handleCreate = async (data: Partial<Tool>) => {
await createTool(data);
setShowCreate(false);
load();
};
const handleEdit = async (data: Partial<Tool>) => {
await updateTool(editTool!.id, data);
setEditTool(null);
load();
};
const handleDelete = async (tool: Tool) => {
if (!confirm(`Delete "${tool.name}"?`)) return;
await deleteTool(tool.id);
load();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Tools</h1>
<p className="text-muted text-sm mt-0.5">{tools.length} tool{tools.length !== 1 ? 's' : ''}{newCount > 0 && ` · ${newCount} new`}</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus size={15} /> Add Tool
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative flex-1 min-w-48">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
className="w-full bg-card border border-border rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<select
value={catFilter}
onChange={(e) => setCatFilter(e.target.value)}
className="bg-card border border-border rounded-lg px-3 py-2 text-sm text-muted focus:outline-none focus:border-[var(--accent)] transition-colors"
>
{cats.map((c) => <option key={c} value={c}>{c === 'all' ? 'All Categories' : c}</option>)}
</select>
<button
onClick={() => setNewOnly(!newOnly)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border transition-colors ${newOnly ? 'bg-[var(--accent-dim)] border-[var(--accent)]/30 text-white' : 'bg-card border-border text-muted hover:text-white'}`}
>
<Sparkles size={13} /> New Only
</button>
</div>
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="text-muted">{tools.length === 0 ? 'No tools yet. Add your first one.' : 'No tools match your filters.'}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
onEdit={() => setEditTool(tool)}
onDelete={() => handleDelete(tool)}
/>
))}
</div>
)}
{showCreate && (
<Modal title="Add Tool" onClose={() => setShowCreate(false)}>
<ToolForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
</Modal>
)}
{editTool && (
<Modal title="Edit Tool" onClose={() => setEditTool(null)}>
<ToolForm initial={editTool} onSubmit={handleEdit} onCancel={() => setEditTool(null)} />
</Modal>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
export interface Project {
id: string;
name: string;
description: string;
category: string;
status: 'active' | 'complete' | 'archived';
completion: number;
external_url: string | null;
drive_url: string | null;
tags: string[];
accent_color: string;
is_new: boolean;
created_at: string;
updated_at: string;
doc_count?: number;
documents?: Document[];
}
export interface Document {
id: string;
project_id: string;
filename: string;
original_name: string;
mimetype: string;
created_at: string;
}
export interface Tool {
id: string;
name: string;
description: string;
category: string;
external_url: string | null;
is_new: boolean;
added_at: string;
notes: string;
}
export interface Settings {
app_title: string;
logo_url: string | null;
accent_color: string;
company_name: string;
}
export type ProjectStatus = 'active' | 'complete' | 'archived';
export interface User {
id: string;
username: string;
role: 'admin' | 'user';
created_at: string;
}
+28
View File
@@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
base: '#0f0f13',
surface: '#16161f',
card: '#1c1c28',
border: '#2a2a3a',
muted: '#6b7280',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.25s ease-out',
},
keyframes: {
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
slideUp: { from: { opacity: '0', transform: 'translateY(8px)' }, to: { opacity: '1', transform: 'translateY(0)' } },
},
},
},
plugins: [],
};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});
+22
View File
@@ -0,0 +1,22 @@
services:
dashboard:
build: .
container_name: ai-tools-dashboard
ports:
- "${PORT:-3000}:3000"
volumes:
- ./data:/data
environment:
- PORT=3000
- DATA_DIR=/data
- MAX_UPLOAD_MB=50
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=codedump2024
- JWT_SECRET=changeme-use-a-long-random-string
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/settings"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
+14
View File
@@ -0,0 +1,14 @@
{
"name": "ai-tools-dashboard",
"version": "1.0.0",
"private": true,
"workspaces": ["client", "server"],
"scripts": {
"dev": "concurrently \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
"build": "npm run build --workspace=client && npm run build --workspace=server",
"start": "npm run start --workspace=server"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "ai-tools-dashboard-server",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.4.3",
"cors": "^2.8.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/node": "^20.11.30",
"@types/uuid": "^9.0.8",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
}
}
+99
View File
@@ -0,0 +1,99 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
export const UPLOAD_PATH = UPLOAD_DIR;
const db = new Database(path.join(DATA_DIR, 'dashboard.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
pin_hash TEXT UNIQUE,
password_hash TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_pin_hash ON users (pin_hash)
WHERE pin_hash IS NOT NULL;
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'General',
status TEXT NOT NULL DEFAULT 'active',
completion INTEGER NOT NULL DEFAULT 0,
external_url TEXT,
drive_url TEXT,
tags TEXT NOT NULL DEFAULT '[]',
accent_color TEXT NOT NULL DEFAULT '#6366f1',
is_new INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mimetype TEXT NOT NULL DEFAULT 'text/markdown',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'General',
external_url TEXT,
is_new INTEGER NOT NULL DEFAULT 1,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
notes TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('app_title', '"CODEDUMP"'),
('logo_url', 'null'),
('accent_color', '"#6366f1"'),
('company_name', '"Your Company"');
`);
function bootstrapAdmin() {
const existing = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get();
if (existing) return;
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPassword = process.env.ADMIN_PASSWORD || 'codedump2024';
const passwordHash = bcrypt.hashSync(adminPassword, 10);
db.prepare(`
INSERT OR IGNORE INTO users (id, username, role, password_hash)
VALUES (?, ?, 'admin', ?)
`).run(uuidv4(), adminUsername, passwordHash);
console.log(`[CODEDUMP] Admin account created → username: "${adminUsername}"`);
console.log(`[CODEDUMP] Change the default password via Admin → Users.`);
}
bootstrapAdmin();
export default db;
+39
View File
@@ -0,0 +1,39 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import './db/schema'; // initialize DB + bootstrap admin
import { requireAuth } from './middleware/auth';
import authRouter from './routes/auth';
import projectsRouter from './routes/projects';
import toolsRouter from './routes/tools';
import uploadsRouter from './routes/uploads';
import settingsRouter from './routes/settings';
import usersRouter from './routes/users';
const app = express();
const PORT = Number(process.env.PORT || 3000);
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Public — auth endpoints (login doesn't require token)
app.use('/api/auth', authRouter);
// Protected — all other API routes require a valid JWT
app.use('/api/projects', requireAuth, projectsRouter);
app.use('/api/tools', requireAuth, toolsRouter);
app.use('/api/uploads', requireAuth, uploadsRouter);
app.use('/api/settings', requireAuth, settingsRouter);
app.use('/api/users', usersRouter); // requireAdmin applied inside router
// Serve built React client in production
const clientDist = path.join(__dirname, '../../client/dist');
app.use(express.static(clientDist));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`[CODEDUMP] Running on port ${PORT}`);
console.log(`[CODEDUMP] Data directory: ${process.env.DATA_DIR || 'data/'}`);
});
+9
View File
@@ -0,0 +1,9 @@
import crypto from 'crypto';
// PINs use HMAC-SHA256 keyed on JWT_SECRET so the hash is deterministic
// (needed for direct DB lookup). bcrypt is non-deterministic and would
// require iterating all users, which is too slow at any reasonable work factor.
export function hashPin(pin: string): string {
const secret = process.env.JWT_SECRET || 'codedump-secret-change-in-production';
return crypto.createHmac('sha256', secret).update(String(pin)).digest('hex');
}
+46
View File
@@ -0,0 +1,46 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export const JWT_SECRET = process.env.JWT_SECRET || 'codedump-secret-change-in-production';
export const JWT_EXPIRY = '12h';
export interface JwtPayload {
id: string;
username: string;
role: 'admin' | 'user';
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = extractToken(req);
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
req.user = jwt.verify(token, JWT_SECRET) as JwtPayload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
requireAuth(req, res, () => {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
});
}
function extractToken(req: Request): string | null {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) return auth.slice(7);
return null;
}
+54
View File
@@ -0,0 +1,54 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import db from '../db/schema';
import { requireAuth, JWT_SECRET, JWT_EXPIRY, JwtPayload } from '../middleware/auth';
import { hashPin } from '../lib/pinHash';
const router = Router();
// POST /api/auth/pin — identify user by unique PIN, no username needed
router.post('/pin', (req: Request, res: Response) => {
const { pin } = req.body;
if (!pin || !/^\d{4}$/.test(String(pin))) {
return res.status(400).json({ error: 'A 4-digit PIN is required' });
}
const pinHash = hashPin(String(pin));
const user = db.prepare(
"SELECT * FROM users WHERE pin_hash = ? AND role = 'user'"
).get(pinHash) as any;
if (!user) return res.status(401).json({ error: 'Invalid PIN' });
const payload: JwtPayload = { id: user.id, username: user.username, role: 'user' };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
res.json({ token, user: payload });
});
// POST /api/auth/login — admin username + password login
router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username and password required' });
}
const user = db.prepare(
"SELECT * FROM users WHERE username = ? AND role = 'admin'"
).get(username) as any;
if (!user || !bcrypt.compareSync(password, user.password_hash || '')) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const payload: JwtPayload = { id: user.id, username: user.username, role: 'admin' };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
res.json({ token, user: payload });
});
// GET /api/auth/me — validate token
router.get('/me', requireAuth, (req: Request, res: Response) => {
res.json(req.user);
});
export default router;
+103
View File
@@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db/schema';
const router = Router();
router.get('/', (_req: Request, res: Response) => {
const projects = db.prepare(`
SELECT p.*,
(SELECT COUNT(*) FROM documents d WHERE d.project_id = p.id) as doc_count
FROM projects p ORDER BY p.updated_at DESC
`).all();
res.json(projects.map(normalizeProject));
});
router.get('/:id', (req: Request, res: Response) => {
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id) as any;
if (!project) return res.status(404).json({ error: 'Not found' });
const docs = db.prepare('SELECT * FROM documents WHERE project_id = ? ORDER BY created_at DESC').all(req.params.id);
res.json({ ...normalizeProject(project), documents: docs });
});
router.post('/', (req: Request, res: Response) => {
const { name, description, category, status, completion, external_url, drive_url, tags, accent_color, is_new } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
const id = uuidv4();
db.prepare(`
INSERT INTO projects (id, name, description, category, status, completion, external_url, drive_url, tags, accent_color, is_new)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
name.trim(),
description || '',
category || 'General',
status || 'active',
completion ?? 0,
external_url || null,
drive_url || null,
JSON.stringify(tags || []),
accent_color || '#6366f1',
is_new ? 1 : 1
);
const created = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as any;
res.status(201).json(normalizeProject(created));
});
router.put('/:id', (req: Request, res: Response) => {
const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { name, description, category, status, completion, external_url, drive_url, tags, accent_color, is_new } = req.body;
db.prepare(`
UPDATE projects SET
name = COALESCE(?, name),
description = COALESCE(?, description),
category = COALESCE(?, category),
status = COALESCE(?, status),
completion = COALESCE(?, completion),
external_url = ?,
drive_url = ?,
tags = COALESCE(?, tags),
accent_color = COALESCE(?, accent_color),
is_new = COALESCE(?, is_new),
updated_at = datetime('now')
WHERE id = ?
`).run(
name ?? null,
description ?? null,
category ?? null,
status ?? null,
completion ?? null,
external_url !== undefined ? (external_url || null) : undefined,
drive_url !== undefined ? (drive_url || null) : undefined,
tags !== undefined ? JSON.stringify(tags) : null,
accent_color ?? null,
is_new !== undefined ? (is_new ? 1 : 0) : null,
req.params.id
);
const updated = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id) as any;
res.json(normalizeProject(updated));
});
router.delete('/:id', (req: Request, res: Response) => {
const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
res.status(204).end();
});
function normalizeProject(p: any) {
return {
...p,
tags: typeof p.tags === 'string' ? JSON.parse(p.tags) : p.tags,
is_new: Boolean(p.is_new),
completion: Number(p.completion),
};
}
export default router;
+55
View File
@@ -0,0 +1,55 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import db, { UPLOAD_PATH } from '../db/schema';
const router = Router();
const logoUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_PATH),
filename: (_req, file, cb) => cb(null, `logo-${uuidv4()}${path.extname(file.originalname)}`),
}),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp'];
cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
},
});
router.get('/', (_req: Request, res: Response) => {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
const settings: Record<string, any> = {};
for (const row of rows) {
try { settings[row.key] = JSON.parse(row.value); } catch { settings[row.key] = row.value; }
}
res.json(settings);
});
router.put('/', (req: Request, res: Response) => {
const allowed = ['app_title', 'logo_url', 'accent_color', 'company_name'];
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
const update = db.transaction((body: Record<string, any>) => {
for (const key of allowed) {
if (key in body) upsert.run(key, JSON.stringify(body[key]));
}
});
update(req.body);
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
const settings: Record<string, any> = {};
for (const row of rows) {
try { settings[row.key] = JSON.parse(row.value); } catch { settings[row.key] = row.value; }
}
res.json(settings);
});
router.post('/logo', logoUpload.single('logo'), (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const url = `/api/uploads/${req.file.filename}`;
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run('logo_url', JSON.stringify(url));
res.json({ logo_url: url });
});
export default router;
+79
View File
@@ -0,0 +1,79 @@
import { Router, Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db/schema';
const router = Router();
router.get('/', (_req: Request, res: Response) => {
const tools = db.prepare('SELECT * FROM tools ORDER BY added_at DESC').all();
res.json(tools.map(normalizeTool));
});
router.get('/:id', (req: Request, res: Response) => {
const tool = db.prepare('SELECT * FROM tools WHERE id = ?').get(req.params.id) as any;
if (!tool) return res.status(404).json({ error: 'Not found' });
res.json(normalizeTool(tool));
});
router.post('/', (req: Request, res: Response) => {
const { name, description, category, external_url, is_new, notes } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
const id = uuidv4();
db.prepare(`
INSERT INTO tools (id, name, description, category, external_url, is_new, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
name.trim(),
description || '',
category || 'General',
external_url || null,
is_new !== false ? 1 : 0,
notes || ''
);
const created = db.prepare('SELECT * FROM tools WHERE id = ?').get(id) as any;
res.status(201).json(normalizeTool(created));
});
router.put('/:id', (req: Request, res: Response) => {
const existing = db.prepare('SELECT id FROM tools WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { name, description, category, external_url, is_new, notes } = req.body;
db.prepare(`
UPDATE tools SET
name = COALESCE(?, name),
description = COALESCE(?, description),
category = COALESCE(?, category),
external_url = COALESCE(?, external_url),
is_new = COALESCE(?, is_new),
notes = COALESCE(?, notes)
WHERE id = ?
`).run(
name ?? null,
description ?? null,
category ?? null,
external_url !== undefined ? (external_url || null) : null,
is_new !== undefined ? (is_new ? 1 : 0) : null,
notes ?? null,
req.params.id
);
const updated = db.prepare('SELECT * FROM tools WHERE id = ?').get(req.params.id) as any;
res.json(normalizeTool(updated));
});
router.delete('/:id', (req: Request, res: Response) => {
const existing = db.prepare('SELECT id FROM tools WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
db.prepare('DELETE FROM tools WHERE id = ?').run(req.params.id);
res.status(204).end();
});
function normalizeTool(t: any) {
return { ...t, is_new: Boolean(t.is_new) };
}
export default router;
+68
View File
@@ -0,0 +1,68 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import db, { UPLOAD_PATH } from '../db/schema';
const router = Router();
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_PATH),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: Number(process.env.MAX_UPLOAD_MB || 50) * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.md', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg'];
const ext = path.extname(file.originalname).toLowerCase();
cb(null, allowed.includes(ext));
},
});
// Upload doc to a project
router.post('/projects/:projectId', upload.single('file'), (req: Request, res: Response) => {
const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const id = uuidv4();
db.prepare(`
INSERT INTO documents (id, project_id, filename, original_name, mimetype)
VALUES (?, ?, ?, ?, ?)
`).run(id, req.params.projectId, req.file.filename, req.file.originalname, req.file.mimetype);
res.status(201).json({
id,
project_id: req.params.projectId,
filename: req.file.filename,
original_name: req.file.originalname,
mimetype: req.file.mimetype,
});
});
// Get raw file
router.get('/:filename', (req: Request, res: Response) => {
const filePath = path.join(UPLOAD_PATH, path.basename(req.params.filename));
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filePath);
});
// Delete a document
router.delete('/documents/:id', (req: Request, res: Response) => {
const doc = db.prepare('SELECT * FROM documents WHERE id = ?').get(req.params.id) as any;
if (!doc) return res.status(404).json({ error: 'Not found' });
const filePath = path.join(UPLOAD_PATH, doc.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
db.prepare('DELETE FROM documents WHERE id = ?').run(req.params.id);
res.status(204).end();
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import db from '../db/schema';
import { requireAdmin } from '../middleware/auth';
import { hashPin } from '../lib/pinHash';
const router = Router();
router.use(requireAdmin);
router.get('/', (_req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, role, created_at FROM users ORDER BY role DESC, username ASC'
).all();
res.json(users);
});
router.post('/', (req: Request, res: Response) => {
const { username, pin, password, role } = req.body;
if (!username?.trim()) return res.status(400).json({ error: 'username is required' });
const userRole = role === 'admin' ? 'admin' : 'user';
if (userRole === 'user') {
if (!pin || !/^\d{4}$/.test(String(pin))) {
return res.status(400).json({ error: 'A 4-digit PIN is required for user accounts' });
}
} else {
if (!password || password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters for admin accounts' });
}
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
if (existing) return res.status(409).json({ error: 'Username already exists' });
let pinHash: string | null = null;
if (userRole === 'user') {
pinHash = hashPin(String(pin));
const pinTaken = db.prepare('SELECT id FROM users WHERE pin_hash = ?').get(pinHash);
if (pinTaken) return res.status(409).json({ error: 'That PIN is already in use by another user. Choose a different PIN.' });
}
const id = uuidv4();
const passwordHash = userRole === 'admin' ? bcrypt.hashSync(password, 10) : null;
db.prepare(`
INSERT INTO users (id, username, role, pin_hash, password_hash)
VALUES (?, ?, ?, ?, ?)
`).run(id, username.trim(), userRole, pinHash, passwordHash);
res.status(201).json(
db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(id)
);
});
router.put('/:id', (req: Request, res: Response) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id) as any;
if (!user) return res.status(404).json({ error: 'Not found' });
const { pin, password, username } = req.body;
if (username?.trim() && username.trim() !== user.username) {
const clash = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username.trim(), req.params.id);
if (clash) return res.status(409).json({ error: 'Username already taken' });
db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username.trim(), req.params.id);
}
if (user.role === 'user' && pin !== undefined && pin !== '') {
if (!/^\d{4}$/.test(String(pin))) {
return res.status(400).json({ error: 'PIN must be exactly 4 digits' });
}
const pinHash = hashPin(String(pin));
const pinTaken = db.prepare('SELECT id FROM users WHERE pin_hash = ? AND id != ?').get(pinHash, req.params.id);
if (pinTaken) return res.status(409).json({ error: 'That PIN is already in use by another user.' });
db.prepare('UPDATE users SET pin_hash = ? WHERE id = ?').run(pinHash, req.params.id);
}
if (user.role === 'admin' && password) {
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(bcrypt.hashSync(password, 10), req.params.id);
}
res.json(
db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(req.params.id)
);
});
router.delete('/:id', (req: Request, res: Response) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id) as any;
if (!user) return res.status(404).json({ error: 'Not found' });
if (user.role === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as c FROM users WHERE role = 'admin'").get() as any).c;
if (adminCount <= 1) return res.status(400).json({ error: 'Cannot delete the last admin account' });
}
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.status(204).end();
});
export default router;
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}