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:
parent
848f03374e
commit
09385a0a18
|
|
@ -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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
.env
|
||||
data.db
|
||||
data.db-journal
|
||||
data.db-shm
|
||||
data.db-wal
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
if (getToken()) renderList();
|
||||
else renderLogin();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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).`);
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue