initial design fix 2
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
*/node_modules
|
|
||||||
*/dist
|
|
||||||
data
|
|
||||||
.git
|
|
||||||
*.log
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# ── 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"]
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# Unraid Install Guide — CODEDUMP
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Unraid 6.10 or later
|
|
||||||
- Docker enabled (default on all modern Unraid installs)
|
|
||||||
- Community Applications plugin installed (recommended, not required)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option A — Community Applications (Easiest)
|
|
||||||
|
|
||||||
> Use this if you have the CA plugin installed.
|
|
||||||
|
|
||||||
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**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option B — Manual Docker Container (No CA Plugin)
|
|
||||||
|
|
||||||
### Step 1 — Open the Docker tab
|
|
||||||
|
|
||||||
In the Unraid web UI, click **Docker** in the top navigation bar.
|
|
||||||
|
|
||||||
### 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
Back this up with Unraid's **Appdata Backup** plugin or any solution that covers `/mnt/user/appdata`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generating a Strong JWT_SECRET
|
|
||||||
|
|
||||||
Run this in an Unraid terminal or any Linux shell:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Paste the output as the value of `JWT_SECRET`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Port Conflicts
|
|
||||||
|
|
||||||
Change **Host Port** to an unused port (e.g. `3100`) and update the WebUI link to match.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
View container logs: **Docker tab** → click the container icon → **Logs**.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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());
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
interface Props {
|
|
||||||
value: number; // 0–100
|
|
||||||
color?: string;
|
|
||||||
showLabel?: boolean;
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressBar({ value, color, showLabel = false, size = 'sm' }: Props) {
|
|
||||||
const pct = Math.min(100, Math.max(0, value));
|
|
||||||
const h = size === 'sm' ? 'h-1.5' : 'h-2.5';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 w-full">
|
|
||||||
<div className={`flex-1 bg-border rounded-full overflow-hidden ${h}`}>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${pct}%`, background: color || 'var(--accent)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showLabel && (
|
|
||||||
<span className="text-xs font-mono text-muted tabular-nums w-9 text-right">{pct}%</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Plus, Pencil, Trash2, Shield, User as UserIcon, Key, RefreshCw } from 'lucide-react';
|
|
||||||
import type { User } from '../types';
|
|
||||||
import { getUsers, createUser, updateUser, deleteUser } from '../api';
|
|
||||||
import Modal from '../components/ui/Modal';
|
|
||||||
import Button from '../components/ui/Button';
|
|
||||||
import Badge from '../components/ui/Badge';
|
|
||||||
|
|
||||||
// ─── PIN input component ──────────────────────────────────────────────────────
|
|
||||||
function PinInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = e.target.value.replace(/\D/g, '').slice(0, 4);
|
|
||||||
onChange(v);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="\d{4}"
|
|
||||||
maxLength={4}
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="0000"
|
|
||||||
className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted font-mono tracking-[0.5em] text-center focus:outline-none focus:border-[var(--accent)] transition-colors"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex gap-1">
|
|
||||||
{[0,1,2,3].map((i) => (
|
|
||||||
<div key={i} className={`w-2 h-2 rounded-full transition-all ${i < value.length ? 'scale-110' : 'bg-border'}`}
|
|
||||||
style={i < value.length ? { background: 'var(--accent)' } : {}} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Create / Edit form ───────────────────────────────────────────────────────
|
|
||||||
function UserForm({ initial, onSubmit, onCancel }: {
|
|
||||||
initial?: User;
|
|
||||||
onSubmit: (d: any) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) {
|
|
||||||
const isEdit = Boolean(initial);
|
|
||||||
const [username, setUsername] = useState(initial?.username || '');
|
|
||||||
const [role, setRole] = useState<'user' | 'admin'>(initial?.role || 'user');
|
|
||||||
const [pin, setPin] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const field = 'w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted focus:outline-none focus:border-[var(--accent)] transition-colors';
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (!isEdit && !username.trim()) { setError('Username is required'); return; }
|
|
||||||
|
|
||||||
if (role === 'user' && !isEdit && (pin.length !== 4)) {
|
|
||||||
setError('A 4-digit PIN is required'); return;
|
|
||||||
}
|
|
||||||
if (role === 'user' && isEdit && pin && pin.length !== 4) {
|
|
||||||
setError('PIN must be exactly 4 digits'); return;
|
|
||||||
}
|
|
||||||
if (role === 'admin' && !isEdit && password.length < 6) {
|
|
||||||
setError('Password must be at least 6 characters'); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const payload: any = {};
|
|
||||||
if (!isEdit) {
|
|
||||||
payload.username = username.trim();
|
|
||||||
payload.role = role;
|
|
||||||
}
|
|
||||||
if (isEdit && username.trim() !== initial?.username) payload.username = username.trim();
|
|
||||||
if (role === 'user' && pin) payload.pin = pin;
|
|
||||||
if (role === 'admin' && password) payload.password = password;
|
|
||||||
|
|
||||||
await onSubmit(payload);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Something went wrong');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-muted font-medium mb-1.5 block">Username</label>
|
|
||||||
<input className={field} value={username} onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="e.g. jsmith" disabled={false} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isEdit && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-muted font-medium mb-1.5 block">Role</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['user', 'admin'] as const).map((r) => (
|
|
||||||
<button key={r} type="button"
|
|
||||||
onClick={() => { setRole(r); setPin(''); setPassword(''); }}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-all ${
|
|
||||||
role === r
|
|
||||||
? 'border-[var(--accent)] bg-[var(--accent-dim)] text-white'
|
|
||||||
: 'border-border text-muted hover:text-white hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{r === 'admin' ? <Shield size={14} /> : <UserIcon size={14} />}
|
|
||||||
{r.charAt(0).toUpperCase() + r.slice(1)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(role === 'user' || initial?.role === 'user') && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-muted font-medium mb-1.5 block">
|
|
||||||
{isEdit ? 'New PIN (leave blank to keep current)' : '4-Digit PIN *'}
|
|
||||||
</label>
|
|
||||||
<PinInput value={pin} onChange={setPin} />
|
|
||||||
<p className="text-xs text-muted/60 mt-1">Numbers only · exactly 4 digits</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(role === 'admin' || initial?.role === 'admin') && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-muted font-medium mb-1.5 block">
|
|
||||||
{isEdit ? 'New Password (leave blank to keep current)' : 'Password *'}
|
|
||||||
</label>
|
|
||||||
<input type="password" className={field} value={password} onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder={isEdit ? '••••••••' : 'Min 6 characters'} autoComplete="new-password" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button type="submit" loading={loading} className="flex-1">
|
|
||||||
{isEdit ? 'Save Changes' : 'Create User'}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" onClick={onCancel}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
|
||||||
export default function AdminUsers() {
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [editUser, setEditUser] = useState<User | null>(null);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const [deleteError, setDeleteError] = useState('');
|
|
||||||
|
|
||||||
const load = () => getUsers().then((u) => setUsers(u as User[])).finally(() => setLoading(false));
|
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const handleCreate = async (data: any) => {
|
|
||||||
await createUser(data);
|
|
||||||
setShowCreate(false);
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = async (data: any) => {
|
|
||||||
await updateUser(editUser!.id, data);
|
|
||||||
setEditUser(null);
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setDeleting(true);
|
|
||||||
setDeleteError('');
|
|
||||||
try {
|
|
||||||
await deleteUser(deleteTarget.id);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
load();
|
|
||||||
} catch (err: any) {
|
|
||||||
setDeleteError(err.message || 'Failed to delete user');
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const admins = users.filter((u) => u.role === 'admin');
|
|
||||||
const regularUsers = users.filter((u) => u.role === 'user');
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserRow = ({ user }: { user: User }) => (
|
|
||||||
<div className="flex items-center gap-4 p-4 rounded-xl border border-border bg-card hover:border-[var(--accent)]/30 transition-all animate-fade-in">
|
|
||||||
<div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shrink-0"
|
|
||||||
style={{ background: user.role === 'admin' ? '#8b5cf6' : 'var(--accent)' }}>
|
|
||||||
{user.username.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-white text-sm">{user.username}</span>
|
|
||||||
{user.role === 'admin'
|
|
||||||
? <Badge variant="complete">Admin</Badge>
|
|
||||||
: <Badge variant="active">User</Badge>}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted mt-0.5">
|
|
||||||
{user.role === 'user' ? <span className="flex items-center gap-1"><Key size={10} /> PIN login</span> : <span className="flex items-center gap-1"><Shield size={10} /> Password login</span>}
|
|
||||||
{' · '}Added {new Date(user.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEditUser(user)}>
|
|
||||||
<RefreshCw size={13} /> Reset {user.role === 'user' ? 'PIN' : 'Password'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEditUser(user)}>
|
|
||||||
<Pencil size={13} />
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" size="sm" onClick={() => { setDeleteTarget(user); setDeleteError(''); }}>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">User Management</h1>
|
|
||||||
<p className="text-muted text-sm mt-0.5">{users.length} account{users.length !== 1 ? 's' : ''}</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setShowCreate(true)}>
|
|
||||||
<Plus size={15} /> New User
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info box */}
|
|
||||||
<div className="bg-[var(--accent-dim)] border border-[var(--accent)]/20 rounded-xl p-4 mb-8 mt-4">
|
|
||||||
<p className="text-xs text-white/70 leading-relaxed">
|
|
||||||
<strong className="text-white">Users</strong> log in with a 4-digit PIN from the main login screen and can update projects, upload docs, and manage tools.{' '}
|
|
||||||
<strong className="text-white">Admins</strong> log in with username & password and also have access to Settings and User Management.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin accounts */}
|
|
||||||
<section className="mb-6">
|
|
||||||
<h2 className="text-sm font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
|
|
||||||
<Shield size={13} /> Admin Accounts ({admins.length})
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{admins.map((u) => <UserRow key={u.id} user={u} />)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Regular users */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-sm font-semibold text-muted uppercase tracking-wider mb-3 flex items-center gap-2">
|
|
||||||
<UserIcon size={13} /> User Accounts ({regularUsers.length})
|
|
||||||
</h2>
|
|
||||||
{regularUsers.length === 0 ? (
|
|
||||||
<div className="text-center py-10 border border-dashed border-border rounded-xl">
|
|
||||||
<UserIcon size={28} className="mx-auto mb-2 text-muted/30" />
|
|
||||||
<p className="text-muted text-sm">No user accounts yet.</p>
|
|
||||||
<button onClick={() => setShowCreate(true)} className="text-xs mt-1 transition-colors" style={{ color: 'var(--accent)' }}>
|
|
||||||
Create the first user →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{regularUsers.map((u) => <UserRow key={u.id} user={u} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
{showCreate && (
|
|
||||||
<Modal title="Create User" onClose={() => setShowCreate(false)}>
|
|
||||||
<UserForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} />
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editUser && (
|
|
||||||
<Modal title={`Edit — ${editUser.username}`} onClose={() => setEditUser(null)}>
|
|
||||||
<UserForm initial={editUser} onSubmit={handleEdit} onCancel={() => setEditUser(null)} />
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deleteTarget && (
|
|
||||||
<Modal title="Delete User" onClose={() => setDeleteTarget(null)}>
|
|
||||||
<p className="text-muted mb-2">
|
|
||||||
Permanently delete <strong className="text-white">{deleteTarget.username}</strong>?
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted/60 mb-6">This cannot be undone.</p>
|
|
||||||
{deleteError && (
|
|
||||||
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4">{deleteError}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button variant="danger" loading={deleting} onClick={handleDelete} className="flex-1">
|
|
||||||
Delete User
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/** @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: [],
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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/'}`);
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user