Add admin panel + wire main site to fetch articles from panel API

- panel/: standalone Express + SQLite + JWT admin (server.js, db.js, seed.js,
  vanilla-JS admin UI under public/). CRUD for articles with cover image
  uploads. README documents API, run instructions, and integration steps.
- src/data/reports.ts: appended bootstrapReports() that fetches from
  VITE_PANEL_API (defaults to http://localhost:3001) and mutates the in-memory
  reports array. Static array kept as fallback when panel is offline.
- src/main.tsx: awaits bootstrapReports() before initial render so all
  existing consumers see panel data without per-file changes.
This commit is contained in:
alireza 2026-05-26 17:11:04 +03:30
parent 848f03374e
commit 09385a0a18
13 changed files with 2523 additions and 1 deletions

14
panel/.env.example Normal file
View File

@ -0,0 +1,14 @@
# Copy this file to ".env" and fill in real values.
# JWT signing secret — change to a long random string in production
JWT_SECRET=please-change-this-to-a-long-random-string
# Default admin credentials used by `npm run seed`
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
# Server port
PORT=3001
# Public origin (used in returned image URLs). Set to your real domain in prod.
PUBLIC_ORIGIN=http://localhost:3001

10
panel/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules
.env
data.db
data.db-journal
data.db-shm
data.db-wal
uploads/*
!uploads/.gitkeep
*.log
.DS_Store

113
panel/README.md Normal file
View File

@ -0,0 +1,113 @@
# Andishkade Foolad — Admin Panel
A standalone Node/Express + SQLite admin panel for managing articles published on the Andishkade Foolad site.
## Features
- Login (single admin, bcrypt-hashed password, JWT session)
- CRUD for articles with all fields used on the site:
title, category, type, author / role / initial, publish date (Jalali string),
pages, price, isFree, summary, body, cover image, tags, featured flag
- Cover image upload (multer, stored on disk under `uploads/`)
- Public read API (`GET /api/articles`) that the main React site can consume
## Stack
- Node 20+
- Express 4
- `better-sqlite3` (single-file `data.db`)
- `multer` for uploads
- `bcryptjs` + `jsonwebtoken` for auth
- Vanilla JS admin UI (no build step)
---
## First-time setup
```bash
cd panel
npm install
cp .env.example .env # then edit .env, especially JWT_SECRET and ADMIN_PASSWORD
npm run seed # creates the admin user from .env
npm start
```
Then open <http://localhost:3001>.
Login with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` values from `.env`.
## API
All write endpoints require `Authorization: Bearer <token>`. The token is returned by `POST /api/auth/login`.
| Method | Path | Auth | Purpose |
|--------|-----------------------|------|-----------------------------------|
| POST | `/api/auth/login` | — | `{username, password}``{token, user}` |
| GET | `/api/auth/me` | yes | current user info |
| GET | `/api/articles` | — | list articles (newest first) |
| GET | `/api/articles/:id` | — | single article |
| POST | `/api/articles` | yes | create article |
| PUT | `/api/articles/:id` | yes | update article |
| DELETE | `/api/articles/:id` | yes | delete article |
| POST | `/api/uploads` | yes | multipart `file``{url}` |
| GET | `/api/health` | — | `{ok: true}` |
### Article shape
```jsonc
{
"id": "abc123…",
"title": "…",
"category": "بازار جهانی",
"type": "special",
"author": "دکتر علی محمدی",
"authorRole": "مدیر ارشد پژوهش",
"authorInitial": "م",
"publishDate": "بهمن ۱۴۰۳",
"pages": 84,
"price": 850000,
"isFree": false,
"summary": "…",
"body": "متن کامل …",
"coverImage": "/uploads/abc.jpg",
"tags": ["تجارت جهانی", "صادرات"],
"featured": true,
"createdAt": "2026-05-26T13:20:00.000Z",
"updatedAt": "2026-05-26T13:20:00.000Z"
}
```
This shape is intentionally compatible with the `Report` type in [`../src/data/reports.ts`](../src/data/reports.ts), so the main site can swap the hardcoded array for `fetch('/api/articles')` without touching its UI components.
## Wiring the main site to the panel
In the main Vite app, replace the hardcoded import:
```ts
// src/lib/articles.ts
const API = import.meta.env.VITE_PANEL_API || 'http://localhost:3001';
export async function fetchReports() {
const res = await fetch(`${API}/api/articles`);
return res.json();
}
```
Then in the consuming components (e.g. `LatestNewsBentoSection.tsx`), call `fetchReports()` inside a `useEffect` / React Query hook instead of the static `reports` array.
Set `VITE_PANEL_API` in the main app's `.env` to the panel's public URL when deploying.
## Deployment notes
- The panel listens on `PORT` (default 3001). It serves the admin UI at `/` and exposes `/api/*`.
- Persist the `data.db` file and the `uploads/` folder — these are your content. Both are gitignored.
- For HF Spaces, the panel is **not** the same image as the main site; deploy it as a separate Space (Docker SDK) or on a small VPS.
- Always change `JWT_SECRET` and `ADMIN_PASSWORD` in production.
## Reset admin password
```bash
sqlite3 data.db "DELETE FROM users WHERE username='admin';"
# edit .env with the new password, then:
npm run seed
```

67
panel/db.js Normal file
View File

@ -0,0 +1,67 @@
import Database from 'better-sqlite3';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = path.join(__dirname, 'data.db');
export const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
category TEXT,
type TEXT,
author TEXT,
author_role TEXT,
author_initial TEXT,
publish_date TEXT,
pages INTEGER DEFAULT 0,
price INTEGER DEFAULT 0,
is_free INTEGER DEFAULT 1,
summary TEXT,
body TEXT,
cover_image TEXT,
tags TEXT,
featured INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured);
`);
export function rowToArticle(row) {
if (!row) return null;
return {
id: row.id,
title: row.title,
category: row.category,
type: row.type,
author: row.author,
authorRole: row.author_role,
authorInitial: row.author_initial,
publishDate: row.publish_date,
pages: row.pages,
price: row.price,
isFree: !!row.is_free,
summary: row.summary,
body: row.body,
coverImage: row.cover_image,
tags: row.tags ? JSON.parse(row.tags) : [],
featured: !!row.featured,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

1579
panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
panel/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "andishkade-foolad-panel",
"version": "1.0.0",
"private": true,
"description": "Admin panel for managing articles published on the Andishkade Foolad site.",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"seed": "node seed.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.0.7"
},
"engines": {
"node": ">=20"
}
}

294
panel/public/app.js Normal file
View File

@ -0,0 +1,294 @@
const $ = (sel, root = document) => root.querySelector(sel);
const root = $('#app');
const TOKEN_KEY = 'andishkade_panel_token';
const getToken = () => localStorage.getItem(TOKEN_KEY);
const setToken = (t) => localStorage.setItem(TOKEN_KEY, t);
const clearToken = () => localStorage.removeItem(TOKEN_KEY);
const CATEGORIES = ['بازار جهانی', 'سیاست‌گذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران'];
const TYPES = [
{ value: 'quarterly', label: 'فصلی' },
{ value: 'flash', label: 'فلش' },
{ value: 'risk', label: 'هشدار ریسک' },
{ value: 'free', label: 'رایگان' },
{ value: 'special', label: 'ویژه' },
];
async function api(path, { method = 'GET', body, isForm = false } = {}) {
const headers = {};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
if (body && !isForm) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method,
headers,
body: isForm ? body : (body ? JSON.stringify(body) : undefined),
});
if (res.status === 401) {
clearToken();
renderLogin('نیاز به ورود مجدد است');
throw new Error('unauthorized');
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
if (res.status === 204) return null;
return res.json();
}
function renderLogin(errorMsg = '') {
root.innerHTML = `
<div class="auth-wrap">
<form class="card auth-card" id="loginForm">
<h1>ورود به پنل مدیریت</h1>
<p class="muted">اندیشکده فولاد</p>
<label>نام کاربری
<input name="username" autocomplete="username" required />
</label>
<label>رمز عبور
<input name="password" type="password" autocomplete="current-password" required />
</label>
${errorMsg ? `<div class="error">${errorMsg}</div>` : ''}
<button type="submit">ورود</button>
</form>
</div>
`;
$('#loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
const data = await api('/api/auth/login', {
method: 'POST',
body: { username: fd.get('username'), password: fd.get('password') },
});
setToken(data.token);
renderList();
} catch {
renderLogin('نام کاربری یا رمز عبور اشتباه است');
}
});
}
async function renderList() {
root.innerHTML = `
<header class="topbar">
<div class="brand">پنل اندیشکده فولاد</div>
<div class="actions">
<button id="newBtn" class="primary">+ مطلب جدید</button>
<button id="logoutBtn" class="ghost">خروج</button>
</div>
</header>
<main class="page">
<h2>مطالب</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>
`;
$('#logoutBtn').addEventListener('click', () => { clearToken(); renderLogin(); });
$('#newBtn').addEventListener('click', () => renderEditor(null));
try {
const items = await api('/api/articles?limit=200');
if (!items.length) {
$('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
return;
}
$('#list').innerHTML = items.map((a) => `
<article class="row" data-id="${a.id}">
<div class="row-cover" style="background-image:url('${a.coverImage || ''}')"></div>
<div class="row-main">
<div class="row-meta">
${a.featured ? '<span class="pill red">ویژه</span>' : ''}
${a.category ? `<span class="pill">${a.category}</span>` : ''}
${a.publishDate ? `<span class="muted">${a.publishDate}</span>` : ''}
</div>
<h3>${a.title}</h3>
<p class="muted clamp-2">${a.summary || ''}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const row = btn.closest('.row');
const id = row.dataset.id;
if (btn.dataset.action === 'edit') {
const a = await api(`/api/articles/${id}`);
renderEditor(a);
} else if (btn.dataset.action === 'delete') {
if (!confirm('این مطلب حذف شود؟')) return;
await api(`/api/articles/${id}`, { method: 'DELETE' });
renderList();
}
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری مطالب.</p>`;
}
}
function renderEditor(article) {
const a = article || {
title: '', category: '', type: '', author: '', authorRole: '', authorInitial: '',
publishDate: '', pages: 0, price: 0, isFree: true, summary: '', body: '',
coverImage: '', tags: [], featured: false,
};
const isEdit = !!article;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش مطلب' : 'مطلب جدید'}</div>
<div class="actions">
<button id="backBtn" class="ghost"> بازگشت</button>
</div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>عنوان
<input name="title" value="${escapeAttr(a.title)}" required />
</label>
</div>
<label>دستهبندی
<select name="category">
<option value=""></option>
${CATEGORIES.map(c => `<option value="${c}" ${c===a.category?'selected':''}>${c}</option>`).join('')}
</select>
</label>
<label>نوع
<select name="type">
<option value=""></option>
${TYPES.map(t => `<option value="${t.value}" ${t.value===a.type?'selected':''}>${t.label}</option>`).join('')}
</select>
</label>
<label>نویسنده
<input name="author" value="${escapeAttr(a.author)}" />
</label>
<label>سمت نویسنده
<input name="authorRole" value="${escapeAttr(a.authorRole)}" />
</label>
<label>حرف ابتدای نام (برای آواتار)
<input name="authorInitial" maxlength="2" value="${escapeAttr(a.authorInitial)}" />
</label>
<label>تاریخ انتشار (شمسی، مثلاً «بهمن ۱۴۰۳»)
<input name="publishDate" value="${escapeAttr(a.publishDate)}" />
</label>
<label>تعداد صفحات
<input name="pages" type="number" min="0" value="${a.pages || 0}" />
</label>
<label>قیمت (ریال) اگر رایگان است صفر
<input name="price" type="number" min="0" value="${a.price || 0}" />
</label>
<label class="checkbox">
<input type="checkbox" name="isFree" ${a.isFree?'checked':''} />
<span>رایگان است</span>
</label>
<label class="checkbox">
<input type="checkbox" name="featured" ${a.featured?'checked':''} />
<span>بهعنوان مطلب ویژه نمایش داده شود</span>
</label>
<div class="col span-2">
<label>برچسبها (با ویرگول فارسی یا انگلیسی جدا کنید)
<input name="tags" value="${escapeAttr((a.tags || []).join('، '))}" />
</label>
</div>
<div class="col span-2">
<label>عکس کاور
<div class="cover-row">
<input type="file" id="coverFile" accept="image/*" />
<input type="hidden" name="coverImage" value="${escapeAttr(a.coverImage)}" />
<img id="coverPreview" alt="" src="${escapeAttr(a.coverImage)}" />
</div>
</label>
</div>
<div class="col span-2">
<label>خلاصه
<textarea name="summary" rows="3">${escapeHtml(a.summary)}</textarea>
</label>
</div>
<div class="col span-2">
<label>متن کامل مطلب
<textarea name="body" rows="14">${escapeHtml(a.body)}</textarea>
</label>
</div>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد و انتشار'}</button>
</div>
</form>
</main>
`;
$('#backBtn').addEventListener('click', () => renderList());
$('#coverFile').addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
$('input[name="coverImage"]').value = url;
$('#coverPreview').src = url;
} catch (err) {
alert('آپلود ناموفق بود: ' + err.message);
}
});
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const tagsRaw = String(fd.get('tags') || '').split(/[,،]/).map(s => s.trim()).filter(Boolean);
const payload = {
title: fd.get('title'),
category: fd.get('category') || null,
type: fd.get('type') || null,
author: fd.get('author') || null,
authorRole: fd.get('authorRole') || null,
authorInitial: fd.get('authorInitial') || null,
publishDate: fd.get('publishDate') || null,
pages: Number(fd.get('pages') || 0),
price: Number(fd.get('price') || 0),
isFree: fd.get('isFree') === 'on',
featured: fd.get('featured') === 'on',
summary: fd.get('summary') || null,
body: fd.get('body') || null,
coverImage: fd.get('coverImage') || null,
tags: tagsRaw,
};
try {
if (isEdit) {
await api(`/api/articles/${article.id}`, { method: 'PUT', body: payload });
} else {
await api('/api/articles', { method: 'POST', body: payload });
}
renderList();
} catch (err) {
alert('ذخیره ناموفق بود: ' + err.message);
}
});
}
function escapeAttr(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
if (getToken()) renderList();
else renderLogin();

13
panel/public/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>پنل مدیریت — اندیشکده فولاد</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/app.js"></script>
</body>
</html>

201
panel/public/style.css Normal file
View File

@ -0,0 +1,201 @@
:root {
--ink: #1a1712;
--ink-3: #4a443c;
--ink-5: #8a8278;
--paper: #fbfaf7;
--rule: #e8e3d8;
--red: #b91c1c;
--green: #15803d;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
font-family: Tahoma, 'Segoe UI', Vazir, sans-serif;
color: var(--ink);
background: var(--paper);
font-size: 14px;
line-height: 1.6;
}
button {
font-family: inherit;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
border: 1px solid var(--ink);
background: transparent;
color: var(--ink);
cursor: pointer;
border-radius: 0;
transition: background .15s;
}
button:hover { background: var(--ink); color: var(--paper); }
button.primary { background: var(--ink); color: var(--paper); }
button.primary:hover { background: #000; }
button.ghost { border-color: var(--rule); color: var(--ink-3); }
button.danger { border-color: var(--red); color: var(--red); }
button.danger:hover { background: var(--red); color: white; }
input, textarea, select {
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
border: 1px solid var(--rule);
background: white;
color: var(--ink);
width: 100%;
border-radius: 0;
}
input:focus, textarea:focus, select:focus {
outline: 2px solid var(--ink);
outline-offset: -2px;
}
label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--ink-3);
margin-bottom: 4px;
}
label > input, label > textarea, label > select {
margin-top: 6px;
font-weight: 400;
}
textarea { resize: vertical; min-height: 80px; }
.muted { color: var(--ink-5); font-size: 12px; }
.error {
color: var(--red);
background: #fef2f2;
padding: 8px 12px;
border-right: 3px solid var(--red);
font-size: 12px;
}
.clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: white;
border: 1px solid var(--rule);
padding: 32px;
width: 100%;
max-width: 380px;
}
.card h1 { font-size: 18px; margin: 0 0 4px; }
.card p { margin: 0 0 24px; }
.card label { margin-top: 14px; }
.card button { width: 100%; margin-top: 20px; }
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 32px;
background: white;
border-bottom: 1px solid var(--rule);
}
.topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; }
.topbar .actions { display: flex; gap: 10px; }
.page { max-width: 1100px; margin: 0 auto; padding: 32px; }
.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 24px; }
.list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
}
.row {
display: grid;
grid-template-columns: 88px 1fr auto;
gap: 16px;
align-items: center;
background: white;
padding: 14px 18px;
}
.row-cover {
width: 88px;
height: 66px;
background-color: #eee;
background-size: cover;
background-position: center;
border: 1px solid var(--rule);
}
.row-main h3 { font-size: 14px; font-weight: 700; margin: 4px 0; }
.row-main p { margin: 0; }
.row-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row-actions { display: flex; gap: 8px; }
.pill {
display: inline-block;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
background: #f2efe8;
color: var(--ink-3);
letter-spacing: .5px;
}
.pill.red { background: var(--red); color: white; }
.editor .grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.editor .col.span-2 { grid-column: 1 / -1; }
.editor .row-end { display: flex; justify-content: flex-start; }
.checkbox {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 8px;
margin-top: 22px;
}
.checkbox input { width: auto; margin: 0; }
.checkbox span { font-weight: 700; color: var(--ink); }
.cover-row {
display: flex;
gap: 16px;
align-items: center;
margin-top: 6px;
}
.cover-row input[type=file] { flex: 1; }
.cover-row img {
width: 120px;
height: 80px;
object-fit: cover;
background: #eee;
border: 1px solid var(--rule);
}
.cover-row img[src=""] { visibility: hidden; }
@media (max-width: 720px) {
.editor .grid { grid-template-columns: 1fr; }
.editor .col.span-2 { grid-column: 1; }
.topbar { padding: 12px 16px; }
.page { padding: 16px; }
.row { grid-template-columns: 64px 1fr; }
.row-actions { grid-column: 1 / -1; }
.row-cover { width: 64px; height: 48px; }
}

20
panel/seed.js Normal file
View File

@ -0,0 +1,20 @@
import 'dotenv/config';
import bcrypt from 'bcryptjs';
import { db } from './db.js';
const username = process.env.ADMIN_USERNAME || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin1234';
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
console.log(`User "${username}" already exists (id=${existing.id}). Skipping.`);
process.exit(0);
}
const hash = bcrypt.hashSync(password, 10);
const info = db
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, hash);
console.log(`Created admin user "${username}" (id=${info.lastInsertRowid}).`);
console.log(`Login with the password from your .env (ADMIN_PASSWORD).`);

165
panel/server.js Normal file
View File

@ -0,0 +1,165 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { db, rowToArticle } from './db.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3001);
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`;
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().slice(0, 8) || '.bin';
cb(null, `${nanoid(12)}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (/^image\/(jpe?g|png|webp|gif|svg\+xml)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are allowed'));
},
});
const app = express();
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.use('/uploads', express.static(uploadsDir, { maxAge: '7d' }));
app.use('/', express.static(path.join(__dirname, 'public')));
function authRequired(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'invalid_token' });
}
}
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username_password_required' });
const row = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username);
if (!row) return res.status(401).json({ error: 'bad_credentials' });
if (!bcrypt.compareSync(password, row.password_hash)) {
return res.status(401).json({ error: 'bad_credentials' });
}
const token = jwt.sign({ sub: row.id, username: row.username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: row.id, username: row.username } });
});
app.get('/api/auth/me', authRequired, (req, res) => {
res.json({ user: req.user });
});
const PUBLIC_FIELDS = `
id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, cover_image,
tags, featured, created_at, updated_at
`;
app.get('/api/articles', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = db
.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles ORDER BY created_at DESC LIMIT ?`)
.all(limit);
res.json(rows.map(rowToArticle));
});
app.get('/api/articles/:id', (req, res) => {
const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToArticle(row));
});
app.post('/api/uploads', authRequired, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no_file' });
const relative = `/uploads/${req.file.filename}`;
res.json({ url: relative, absoluteUrl: `${PUBLIC_ORIGIN}${relative}` });
});
function normalizeArticleBody(b) {
return {
title: String(b.title || '').trim(),
category: b.category || null,
type: b.type || null,
author: b.author || null,
author_role: b.authorRole || null,
author_initial: b.authorInitial || null,
publish_date: b.publishDate || null,
pages: Number(b.pages) || 0,
price: Number(b.price) || 0,
is_free: b.isFree ? 1 : 0,
summary: b.summary || null,
body: b.body || null,
cover_image: b.coverImage || null,
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
featured: b.featured ? 1 : 0,
};
}
app.post('/api/articles', authRequired, (req, res) => {
const a = normalizeArticleBody(req.body || {});
if (!a.title) return res.status(400).json({ error: 'title_required' });
const id = nanoid(14);
db.prepare(`
INSERT INTO articles (
id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, cover_image,
tags, featured
) VALUES (
@id, @title, @category, @type, @author, @author_role, @author_initial,
@publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
@tags, @featured
)
`).run({ id, ...a });
const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id);
res.status(201).json(rowToArticle(row));
});
app.put('/api/articles/:id', authRequired, (req, res) => {
const existing = db.prepare('SELECT id FROM articles WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'not_found' });
const a = normalizeArticleBody(req.body || {});
if (!a.title) return res.status(400).json({ error: 'title_required' });
db.prepare(`
UPDATE articles SET
title = @title, category = @category, type = @type,
author = @author, author_role = @author_role, author_initial = @author_initial,
publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free,
summary = @summary, body = @body, cover_image = @cover_image,
tags = @tags, featured = @featured,
updated_at = datetime('now')
WHERE id = @id
`).run({ id: req.params.id, ...a });
const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
res.json(rowToArticle(row));
});
app.delete('/api/articles/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
});

View File

@ -333,3 +333,20 @@ export const reports: Report[] = [
featured: false,
},
];
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env
?.VITE_PANEL_API || 'http://localhost:3001';
export async function bootstrapReports(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/articles?limit=200`);
if (!res.ok) return;
const fetched = (await res.json()) as Report[];
if (!Array.isArray(fetched) || fetched.length === 0) return;
reports.length = 0;
reports.push(...fetched);
} catch {
// panel offline → keep the static fallback above
}
}

View File

@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import './index.css'
import { router } from './app/router'
import { bootstrapReports } from './data/reports'
await bootstrapReports()
createRoot(document.getElementById('root')!).render(
<StrictMode>