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,
|
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,9 +3,12 @@ import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { router } from './app/router'
|
import { router } from './app/router'
|
||||||
|
import { bootstrapReports } from './data/reports'
|
||||||
|
|
||||||
|
await bootstrapReports()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
Loading…
Reference in New Issue