feat: Phase 1 - Admin CMS with Links Management
Backend (updates-api): - Add links table schema with categories, visibility, sorting - Implement full CRUD API for links (/v1/links, /v1/admin/links) - Create admin UI for links management (/admin/links) - Add navigation between Updates and Links admin pages - Add comprehensive tests for links API (7/7 passing) Frontend (Next.js): - Create API client for fetching links from updates-api - Add LinksSection component with category icons - Integrate links into homepage (between Notes and CTA) - Add i18n entries for links section (DE/EN) - ISR caching: 60s for links, 5min for updates Features: - Links organized by category (external/internal/resource/tool) - Visibility toggle (show/hide on homepage) - Sort order support - Optional icons and descriptions - Responsive grid layout (1/2/3 columns) Testing: - All existing tests pass - New links API tests cover CRUD + validation - Production build successful Phase 1 Complete: Links management ready for deployment Next: Phase 2 (Markdown editor + media upload)
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
-- Migration: Add links table for link management
|
||||
-- Created: 2026-08-22
|
||||
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'external' CHECK (category IN ('external', 'internal', 'resource', 'tool')),
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
visible INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_visible ON links(visible, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category, visible, sort_order);
|
||||
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>W-MAKE Links Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #12100e;
|
||||
--raised: #1b1814;
|
||||
--paper: #f4efe4;
|
||||
--copper: #c9843a;
|
||||
--mute: #9a9184;
|
||||
--line: rgba(244, 239, 228, 0.14);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font: 16px/1.5 ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem; }
|
||||
header { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; border-bottom: 1px solid var(--line); }
|
||||
h1 { font-size: 1.4rem; font-weight: 550; margin: 0; }
|
||||
.kicker { color: var(--copper); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
nav { display: flex; gap: 1.5rem; margin-top: 1rem; border-bottom: 1px solid var(--line); }
|
||||
nav a { color: var(--mute); text-decoration: none; padding: 0.5rem 0; border-bottom: 2px solid transparent; }
|
||||
nav a:hover, nav a.active { color: var(--paper); border-bottom-color: var(--copper); }
|
||||
.layout { display: grid; grid-template-columns: 320px 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
||||
.list { border-right: 1px solid var(--line); padding-right: 1rem; }
|
||||
.item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: transparent; color: inherit; border: 0; border-bottom: 1px solid var(--line);
|
||||
padding: 0.8rem 0; cursor: pointer;
|
||||
}
|
||||
.item.active { color: var(--copper); }
|
||||
.item small { font-size: 0.75rem; color: var(--mute); display: block; margin-top: 0.2rem; }
|
||||
.status { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--mute); }
|
||||
.status.visible { color: var(--copper); }
|
||||
label { display: grid; gap: 0.35rem; margin-bottom: 0.9rem; font-size: 0.8rem; color: var(--mute); letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
input, select, textarea {
|
||||
width: 100%; background: var(--raised); color: var(--paper);
|
||||
border: 1px solid var(--line); padding: 0.65rem 0.75rem; font: inherit;
|
||||
}
|
||||
textarea { min-height: 120px; }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 860px) { .split { grid-template-columns: 1fr; } }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
|
||||
.checkbox-label input[type="checkbox"] { width: auto; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
||||
button {
|
||||
border: 1px solid var(--copper); background: var(--copper); color: var(--ink);
|
||||
padding: 0.55rem 0.9rem; cursor: pointer; font: inherit;
|
||||
}
|
||||
button.ghost { background: transparent; color: var(--paper); border-color: var(--line); }
|
||||
button.danger { background: transparent; color: #e74c3c; border-color: #e74c3c; }
|
||||
.msg { margin-top: 1rem; color: var(--copper); min-height: 1.4rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<p class="kicker">Authelia · updates.w-make.com</p>
|
||||
<h1>Links-Verwaltung</h1>
|
||||
</div>
|
||||
<button class="ghost" id="new-btn" type="button">Neuer Link</button>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<a href="/admin">Updates</a>
|
||||
<a href="/admin/links" class="active">Links</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="list">
|
||||
<p class="kicker">Alle Links (<span id="count">0</span>)</p>
|
||||
<div id="items"></div>
|
||||
</aside>
|
||||
|
||||
<form id="form">
|
||||
<input type="hidden" name="id">
|
||||
|
||||
<label>Titel *
|
||||
<input type="text" name="title" required maxlength="200">
|
||||
</label>
|
||||
|
||||
<label>URL *
|
||||
<input type="url" name="url" required placeholder="https://...">
|
||||
</label>
|
||||
|
||||
<div class="split">
|
||||
<label>Kategorie
|
||||
<select name="category">
|
||||
<option value="external">Extern</option>
|
||||
<option value="internal">Intern</option>
|
||||
<option value="resource">Ressource</option>
|
||||
<option value="tool">Tool</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>Sortierung
|
||||
<input type="number" name="sort_order" value="0" min="0" max="999">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>Beschreibung
|
||||
<textarea name="description" maxlength="500" placeholder="Optionale Beschreibung..."></textarea>
|
||||
</label>
|
||||
|
||||
<label>Icon (optional)
|
||||
<input type="text" name="icon" placeholder="z.B. fa-external-link oder emoji">
|
||||
</label>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="visible" checked>
|
||||
<span>Sichtbar auf der Homepage</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" class="ghost" id="cancel-btn">Abbrechen</button>
|
||||
<button type="button" class="danger" id="delete-btn" style="margin-left: auto; display: none;">Löschen</button>
|
||||
</div>
|
||||
|
||||
<p class="msg" id="msg"></p>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
const API_BASE = '';
|
||||
const token = localStorage.getItem('admin_token') || '';
|
||||
|
||||
let links = [];
|
||||
let currentId = null;
|
||||
|
||||
const els = {
|
||||
items: document.getElementById('items'),
|
||||
count: document.getElementById('count'),
|
||||
form: document.getElementById('form'),
|
||||
msg: document.getElementById('msg'),
|
||||
newBtn: document.getElementById('new-btn'),
|
||||
cancelBtn: document.getElementById('cancel-btn'),
|
||||
deleteBtn: document.getElementById('delete-btn')
|
||||
};
|
||||
|
||||
async function fetchLinks() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/admin/links`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error('Fetch failed');
|
||||
const data = await res.json();
|
||||
links = data.data || [];
|
||||
renderList();
|
||||
} catch (e) {
|
||||
showMsg('Fehler beim Laden der Links', true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
els.count.textContent = links.length;
|
||||
els.items.innerHTML = links.map(link => `
|
||||
<button type="button" class="item ${link.id === currentId ? 'active' : ''}" data-id="${link.id}">
|
||||
<div>${link.title}</div>
|
||||
<small>${link.category} · ${link.visible ? '✓ sichtbar' : '✗ versteckt'}</small>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
els.items.querySelectorAll('.item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = Number(btn.dataset.id);
|
||||
selectLink(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectLink(id) {
|
||||
const link = links.find(l => l.id === id);
|
||||
if (!link) return;
|
||||
|
||||
currentId = id;
|
||||
els.form.elements.id.value = id;
|
||||
els.form.elements.title.value = link.title;
|
||||
els.form.elements.url.value = link.url;
|
||||
els.form.elements.category.value = link.category;
|
||||
els.form.elements.description.value = link.description || '';
|
||||
els.form.elements.icon.value = link.icon || '';
|
||||
els.form.elements.sort_order.value = link.sort_order;
|
||||
els.form.elements.visible.checked = link.visible === 1;
|
||||
|
||||
els.deleteBtn.style.display = 'block';
|
||||
renderList();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentId = null;
|
||||
els.form.reset();
|
||||
els.form.elements.id.value = '';
|
||||
els.form.elements.visible.checked = true;
|
||||
els.deleteBtn.style.display = 'none';
|
||||
renderList();
|
||||
showMsg('');
|
||||
}
|
||||
|
||||
function showMsg(text, isError = false) {
|
||||
els.msg.textContent = text;
|
||||
els.msg.style.color = isError ? '#e74c3c' : 'var(--copper)';
|
||||
}
|
||||
|
||||
els.newBtn.addEventListener('click', resetForm);
|
||||
els.cancelBtn.addEventListener('click', resetForm);
|
||||
|
||||
els.form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
title: els.form.elements.title.value.trim(),
|
||||
url: els.form.elements.url.value.trim(),
|
||||
category: els.form.elements.category.value,
|
||||
description: els.form.elements.description.value.trim() || null,
|
||||
icon: els.form.elements.icon.value.trim() || null,
|
||||
sort_order: Number(els.form.elements.sort_order.value),
|
||||
visible: els.form.elements.visible.checked ? 1 : 0
|
||||
};
|
||||
|
||||
try {
|
||||
const isNew = !currentId;
|
||||
const url = isNew
|
||||
? `${API_BASE}/v1/admin/links`
|
||||
: `${API_BASE}/v1/admin/links/${currentId}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isNew ? 'POST' : 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error?.message || 'Speichern fehlgeschlagen');
|
||||
}
|
||||
|
||||
showMsg(isNew ? 'Link erstellt' : 'Link aktualisiert');
|
||||
await fetchLinks();
|
||||
|
||||
if (isNew) {
|
||||
const data = await res.json();
|
||||
selectLink(data.data.id);
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg(e.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
els.deleteBtn.addEventListener('click', async () => {
|
||||
if (!currentId) return;
|
||||
if (!confirm('Link wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/admin/links/${currentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Löschen fehlgeschlagen');
|
||||
|
||||
showMsg('Link gelöscht');
|
||||
resetForm();
|
||||
await fetchLinks();
|
||||
} catch (e) {
|
||||
showMsg(e.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchLinks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,6 +23,9 @@
|
||||
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem; }
|
||||
header { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; border-bottom: 1px solid var(--line); }
|
||||
h1 { font-size: 1.4rem; font-weight: 550; margin: 0; }
|
||||
nav { display: flex; gap: 1.5rem; margin-top: 1rem; border-bottom: 1px solid var(--line); }
|
||||
nav a { color: var(--mute); text-decoration: none; padding: 0.5rem 0; border-bottom: 2px solid transparent; }
|
||||
nav a:hover, nav a.active { color: var(--paper); border-bottom-color: var(--copper); }
|
||||
.kicker { color: var(--copper); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
||||
@@ -64,6 +67,12 @@
|
||||
</div>
|
||||
<button class="ghost" id="new-btn" type="button">Neues Update</button>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<a href="/admin" class="active">Updates</a>
|
||||
<a href="/admin/links">Links</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="list">
|
||||
<p class="kicker">Bestand</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const ADMIN_HTML = readFileSync(new URL('./admin.html', import.meta.url), 'utf8');
|
||||
const ADMIN_LINKS_HTML = readFileSync(new URL('./admin-links.html', import.meta.url), 'utf8');
|
||||
|
||||
const PRODUCTS = new Set(['batchmaker', 'standalone']);
|
||||
const STATUSES = new Set(['draft', 'published', 'archived']);
|
||||
@@ -82,6 +83,21 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
||||
link_url TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_updates_public ON updates(product, status, published_at DESC);`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS links (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'external' CHECK (category IN ('external', 'internal', 'resource', 'tool')),
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
visible INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_visible ON links(visible, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category, visible, sort_order);`);
|
||||
|
||||
function publishedFilter(product) {
|
||||
return product ? { sql: 'product = ? AND status = ? AND published_at IS NOT NULL AND published_at <= ?', args: [product, 'published', now()] } : { sql: 'status = ? AND published_at IS NOT NULL AND published_at <= ?', args: ['published', now()] };
|
||||
@@ -137,6 +153,91 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
||||
return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
// Links CRUD functions
|
||||
const CATEGORIES = new Set(['external', 'internal', 'resource', 'tool']);
|
||||
|
||||
function validateLinkPayload(payload, partial = false) {
|
||||
const fields = {};
|
||||
if (!partial || 'title' in payload) {
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) {
|
||||
throw Object.assign(new Error('title is required'), { code: 'INVALID_FIELD' });
|
||||
}
|
||||
fields.title = payload.title.trim();
|
||||
}
|
||||
if (!partial || 'url' in payload) {
|
||||
if (typeof payload.url !== 'string' || !payload.url.trim()) {
|
||||
throw Object.assign(new Error('url is required'), { code: 'INVALID_FIELD' });
|
||||
}
|
||||
fields.url = payload.url.trim();
|
||||
}
|
||||
if ('category' in payload) {
|
||||
if (!CATEGORIES.has(payload.category)) {
|
||||
throw Object.assign(new Error('category must be external, internal, resource, or tool'), { code: 'INVALID_FIELD' });
|
||||
}
|
||||
fields.category = payload.category;
|
||||
}
|
||||
if ('description' in payload) {
|
||||
fields.description = typeof payload.description === 'string' ? payload.description.trim() : null;
|
||||
}
|
||||
if ('icon' in payload) {
|
||||
fields.icon = typeof payload.icon === 'string' ? payload.icon.trim() : null;
|
||||
}
|
||||
if ('sort_order' in payload) {
|
||||
fields.sort_order = Number(payload.sort_order) || 0;
|
||||
}
|
||||
if ('visible' in payload) {
|
||||
fields.visible = payload.visible ? 1 : 0;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function linksGetPublic() {
|
||||
return db.prepare('SELECT id, title, url, category, description, icon, sort_order FROM links WHERE visible = 1 ORDER BY sort_order, id').all();
|
||||
}
|
||||
|
||||
function linksAdminList() {
|
||||
return db.prepare('SELECT * FROM links ORDER BY sort_order, id').all();
|
||||
}
|
||||
|
||||
function linksAdminGet(id) {
|
||||
return db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function linksAdminCreate(payload) {
|
||||
const fields = validateLinkPayload(payload);
|
||||
const created = now();
|
||||
db.prepare('INSERT INTO links (title, url, category, description, icon, sort_order, visible, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(fields.title, fields.url, fields.category || 'external', fields.description || null, fields.icon || null, fields.sort_order || 0, fields.visible ?? 1, created, created);
|
||||
return db.prepare('SELECT * FROM links WHERE id = last_insert_rowid()').get();
|
||||
}
|
||||
|
||||
function linksAdminPatch(id, payload) {
|
||||
const current = db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||
if (!current) return null;
|
||||
const fields = validateLinkPayload(payload, true);
|
||||
const updated = now();
|
||||
db.prepare('UPDATE links SET title = ?, url = ?, category = ?, description = ?, icon = ?, sort_order = ?, visible = ?, updated_at = ? WHERE id = ?')
|
||||
.run(
|
||||
fields.title ?? current.title,
|
||||
fields.url ?? current.url,
|
||||
fields.category ?? current.category,
|
||||
fields.description ?? current.description,
|
||||
fields.icon ?? current.icon,
|
||||
fields.sort_order ?? current.sort_order,
|
||||
fields.visible ?? current.visible,
|
||||
updated,
|
||||
id
|
||||
);
|
||||
return db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function linksAdminDelete(id) {
|
||||
const current = db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||
if (!current) return null;
|
||||
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
||||
return current;
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const path = url.pathname;
|
||||
@@ -165,12 +266,18 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
||||
return item ? json(res, 200, { data: item }, { 'cache-control': 'public, max-age=60' }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
||||
}
|
||||
const isAdminUi = path === '/admin' || path === '/admin/';
|
||||
if ((isAdminUi || path.startsWith('/v1/admin/')) && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
|
||||
const isAdminLinksUi = path === '/admin/links' || path === '/admin/links/';
|
||||
if ((isAdminUi || isAdminLinksUi || path.startsWith('/v1/admin/')) && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
|
||||
if (req.method === 'GET' && isAdminUi) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(ADMIN_HTML);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && isAdminLinksUi) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(ADMIN_LINKS_HTML);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && path === '/v1/admin/updates') {
|
||||
const product = url.searchParams.get('product') || null;
|
||||
const status = url.searchParams.get('status') || null;
|
||||
@@ -188,6 +295,31 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
||||
const item = adminMatch[2] ? setStatus(Number(adminMatch[1]), adminMatch[2] === 'publish' ? 'published' : 'archived') : adminPatch(Number(adminMatch[1]), parsePayload(await readBody(req)));
|
||||
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
||||
}
|
||||
|
||||
// Links routes
|
||||
if (req.method === 'GET' && path === '/v1/links') {
|
||||
return json(res, 200, { data: linksGetPublic() }, { 'cache-control': 'public, max-age=60' });
|
||||
}
|
||||
if (req.method === 'GET' && path === '/v1/admin/links') {
|
||||
return json(res, 200, { data: linksAdminList() }, { 'cache-control': 'no-store' });
|
||||
}
|
||||
if (req.method === 'POST' && path === '/v1/admin/links') {
|
||||
return json(res, 201, { data: linksAdminCreate(parsePayload(await readBody(req))) });
|
||||
}
|
||||
const linkMatch = path.match(/^\/v1\/admin\/links\/(\d+)$/);
|
||||
if (linkMatch && req.method === 'GET') {
|
||||
const item = linksAdminGet(Number(linkMatch[1]));
|
||||
return item ? json(res, 200, { data: item }, { 'cache-control': 'no-store' }) : error(res, 404, 'NOT_FOUND', 'link not found');
|
||||
}
|
||||
if (linkMatch && req.method === 'PATCH') {
|
||||
const item = linksAdminPatch(Number(linkMatch[1]), parsePayload(await readBody(req)));
|
||||
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'link not found');
|
||||
}
|
||||
if (linkMatch && req.method === 'DELETE') {
|
||||
const item = linksAdminDelete(Number(linkMatch[1]));
|
||||
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'link not found');
|
||||
}
|
||||
|
||||
return error(res, 404, 'NOT_FOUND', 'route not found');
|
||||
} catch (e) {
|
||||
if (e.code === 'BODY_TOO_LARGE') return error(res, 413, e.code, e.message);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createApp } from '../src/server.js';
|
||||
|
||||
const TOKEN = 'test-token-' + randomUUID();
|
||||
|
||||
test('links CRUD workflow', async () => {
|
||||
const dbPath = join(tmpdir(), `links-test-${randomUUID()}.sqlite`);
|
||||
const app = createApp({ dbPath, adminToken: TOKEN });
|
||||
const port = 3000 + Math.floor(Math.random() * 1000);
|
||||
const server = app.server.listen(port);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
try {
|
||||
// Create link
|
||||
let res = await fetch(`${base}/v1/admin/links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'Test Link',
|
||||
url: 'https://example.com',
|
||||
category: 'external',
|
||||
description: 'A test link',
|
||||
visible: true,
|
||||
sort_order: 5
|
||||
})
|
||||
});
|
||||
assert.equal(res.status, 201, 'create should return 201');
|
||||
const created = await res.json();
|
||||
assert.ok(created.data.id, 'created link should have id');
|
||||
assert.equal(created.data.title, 'Test Link');
|
||||
const linkId = created.data.id;
|
||||
|
||||
// List all links (admin)
|
||||
res = await fetch(`${base}/v1/admin/links`, {
|
||||
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
let list = await res.json();
|
||||
assert.equal(list.data.length, 1, 'should have 1 link');
|
||||
|
||||
// Get single link
|
||||
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const single = await res.json();
|
||||
assert.equal(single.data.title, 'Test Link');
|
||||
|
||||
// Update link
|
||||
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'Updated Link',
|
||||
visible: false
|
||||
})
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const updated = await res.json();
|
||||
assert.equal(updated.data.title, 'Updated Link');
|
||||
assert.equal(updated.data.visible, 0);
|
||||
|
||||
// Public endpoint should not show invisible links
|
||||
res = await fetch(`${base}/v1/links`);
|
||||
assert.equal(res.status, 200);
|
||||
const publicLinks = await res.json();
|
||||
assert.equal(publicLinks.data.length, 0, 'invisible links should not appear in public endpoint');
|
||||
|
||||
// Make visible again
|
||||
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ visible: true })
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// Public endpoint should now show link
|
||||
res = await fetch(`${base}/v1/links`);
|
||||
const publicVisible = await res.json();
|
||||
assert.equal(publicVisible.data.length, 1, 'visible links should appear in public endpoint');
|
||||
|
||||
// Delete link
|
||||
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// Verify deletion
|
||||
res = await fetch(`${base}/v1/admin/links`, {
|
||||
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||
});
|
||||
list = await res.json();
|
||||
assert.equal(list.data.length, 0, 'link should be deleted');
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('links validation', async () => {
|
||||
const dbPath = join(tmpdir(), `links-validation-${randomUUID()}.sqlite`);
|
||||
const app = createApp({ dbPath, adminToken: TOKEN });
|
||||
const port = 3001 + Math.floor(Math.random() * 1000);
|
||||
const server = app.server.listen(port);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
try {
|
||||
// Missing title
|
||||
let res = await fetch(`${base}/v1/admin/links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com'
|
||||
})
|
||||
});
|
||||
assert.equal(res.status, 400, 'should reject missing title');
|
||||
|
||||
// Missing URL
|
||||
res = await fetch(`${base}/v1/admin/links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'Test'
|
||||
})
|
||||
});
|
||||
assert.equal(res.status, 400, 'should reject missing url');
|
||||
|
||||
// Invalid category
|
||||
res = await fetch(`${base}/v1/admin/links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'Test',
|
||||
url: 'https://example.com',
|
||||
category: 'invalid'
|
||||
})
|
||||
});
|
||||
assert.equal(res.status, 400, 'should reject invalid category');
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user