Revert "Add private categories, unlockable via a password-gated cookie login"
This reverts commit 7546c0388e.
This commit is contained in:
@@ -9,10 +9,3 @@ MEDIA_DIR=./data/media
|
|||||||
# header). It changes on every restart, so check the console output each time.
|
# header). It changes on every restart, so check the console output each time.
|
||||||
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Optional — unlocks "private" categories (marked in the admin panel's Category
|
|
||||||
# priority list) for visitors who log in with this password on the public site's
|
|
||||||
# lock icon. Leave unset to disable private categories entirely (they stay hidden
|
|
||||||
# from everyone, with no way to unlock them). Unlike the admin API key above, this
|
|
||||||
# is a fixed password you choose, and the resulting login persists across restarts.
|
|
||||||
# PRIVATE_ACCESS_PASSWORD=
|
|
||||||
|
|||||||
Generated
+6
-40
@@ -9,7 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"fastify": "^5.10.0",
|
"fastify": "^5.10.0",
|
||||||
@@ -696,26 +695,6 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"fast-uri": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/cookie": {
|
|
||||||
"version": "11.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
|
|
||||||
"integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "^2.0.0",
|
|
||||||
"fastify-plugin": "^6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fastify/cors": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "11.3.0",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
||||||
@@ -1295,19 +1274,6 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -1437,9 +1403,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stringify/node_modules/fast-uri": {
|
"node_modules/fast-json-stringify/node_modules/fast-uri": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz",
|
||||||
"integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==",
|
"integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1462,9 +1428,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
},
|
},
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"fastify": "^5.10.0",
|
"fastify": "^5.10.0",
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
|||||||
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Categories (add/remove — reordering/privacy is via PATCH /settings above) ---
|
// --- Categories (add/remove — reordering is via PATCH /settings above) ---
|
||||||
app.post('/api/admin/categories', async (req, reply) => {
|
app.post('/api/admin/categories', async (req, reply) => {
|
||||||
const { name, isPrivate } = req.body as { name?: string; isPrivate?: boolean };
|
const { name } = req.body as { name?: string };
|
||||||
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
|
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
|
||||||
const created = categoriesDb.createCategory(name.trim(), !!isPrivate);
|
const created = categoriesDb.createCategory(name.trim());
|
||||||
return reply.code(201).send(created);
|
return reply.code(201).send(created);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
// Gates "private" categories (see storage/db/categories.ts's is_private column) behind a
|
|
||||||
// single shared password configured in the backend's own .env — deliberately separate
|
|
||||||
// from the admin API key (that's a header-based credential for the admin SPA only; this
|
|
||||||
// is a cookie so a plain visitor's browser can carry it across ordinary page loads).
|
|
||||||
//
|
|
||||||
// There's no per-visitor session store: the cookie's value is a deterministic hash of the
|
|
||||||
// configured password, so any request can be checked statelessly by recomputing that same
|
|
||||||
// hash and comparing — same "no session table" philosophy as the admin API key.
|
|
||||||
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import crypto from 'node:crypto';
|
|
||||||
import { logger } from '../storage/db/logs.js';
|
|
||||||
|
|
||||||
const PRIVATE_ACCESS_PASSWORD = process.env.PRIVATE_ACCESS_PASSWORD || '';
|
|
||||||
export const PRIVATE_ACCESS_COOKIE = 'hf_private';
|
|
||||||
// Browsers cap persistent cookies at ~400 days regardless of what's requested (Chrome,
|
|
||||||
// Firefox, Safari all enforce this) — asking for 10 years just means "the maximum they'll
|
|
||||||
// actually allow," which is as close to "retained indefinitely" as a cookie can get.
|
|
||||||
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10;
|
|
||||||
|
|
||||||
function expectedToken(): string {
|
|
||||||
return crypto.createHash('sha256').update(PRIVATE_ACCESS_PASSWORD).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feature is off entirely (no visitor can ever unlock private categories) until a password is configured. */
|
|
||||||
export function privateAccessConfigured(): boolean {
|
|
||||||
return PRIVATE_ACCESS_PASSWORD.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasPrivateAccess(req: { cookies?: Record<string, string | undefined> }): boolean {
|
|
||||||
if (!privateAccessConfigured()) return false;
|
|
||||||
const token = req.cookies?.[PRIVATE_ACCESS_COOKIE];
|
|
||||||
if (!token) return false;
|
|
||||||
const expected = expectedToken();
|
|
||||||
// Buffers must be equal length for timingSafeEqual — a mismatched length (e.g. a
|
|
||||||
// tampered/truncated cookie) would throw rather than just failing the comparison.
|
|
||||||
if (token.length !== expected.length) return false;
|
|
||||||
try {
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerPrivateAccess(app: FastifyInstance) {
|
|
||||||
app.post('/api/private-access/login', async (req, reply) => {
|
|
||||||
if (!privateAccessConfigured()) {
|
|
||||||
return reply.code(503).send({ error: 'Private categories are not configured on this server' });
|
|
||||||
}
|
|
||||||
const { password } = req.body as { password?: string };
|
|
||||||
const attempt = Buffer.from(password ?? '');
|
|
||||||
const expected = Buffer.from(PRIVATE_ACCESS_PASSWORD);
|
|
||||||
const valid = attempt.length === expected.length && crypto.timingSafeEqual(attempt, expected);
|
|
||||||
if (!valid) {
|
|
||||||
logger.warn('private-access', 'Rejected private-category login attempt with wrong password');
|
|
||||||
return reply.code(401).send({ error: 'Incorrect password' });
|
|
||||||
}
|
|
||||||
reply.setCookie(PRIVATE_ACCESS_COOKIE, expectedToken(), {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: COOKIE_MAX_AGE_SECONDS
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/private-access/logout', async (_req, reply) => {
|
|
||||||
reply.clearCookie(PRIVATE_ACCESS_COOKIE, { path: '/' });
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/private-access/status', async (req) => {
|
|
||||||
return { authenticated: hasPrivateAccess(req as any), configured: privateAccessConfigured() };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -3,37 +3,24 @@ import * as articlesDb from '../storage/db/articles.js';
|
|||||||
import * as tagsDb from '../storage/db/tags.js';
|
import * as tagsDb from '../storage/db/tags.js';
|
||||||
import * as eventsDb from '../storage/db/events.js';
|
import * as eventsDb from '../storage/db/events.js';
|
||||||
import * as categoriesDb from '../storage/db/categories.js';
|
import * as categoriesDb from '../storage/db/categories.js';
|
||||||
import { hasPrivateAccess } from './privateAccess.js';
|
|
||||||
|
|
||||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/feed', async (req) => {
|
app.get('/api/feed', async (req) => {
|
||||||
const { category, geo, eventId, tag, before, limit } = req.query as Record<string, string | undefined>;
|
const { category, geo, eventId, tag, before, limit } = req.query as Record<string, string | undefined>;
|
||||||
return articlesDb.queryFeed(
|
return articlesDb.queryFeed({
|
||||||
{
|
|
||||||
category,
|
category,
|
||||||
geo,
|
geo,
|
||||||
eventId,
|
eventId,
|
||||||
tag,
|
tag,
|
||||||
before,
|
before,
|
||||||
limit: limit ? Number(limit) : undefined
|
limit: limit ? Number(limit) : undefined
|
||||||
},
|
});
|
||||||
hasPrivateAccess(req)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/article/:id', async (req, reply) => {
|
app.get('/api/article/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const article = articlesDb.getArticle(id);
|
const article = articlesDb.getArticle(id);
|
||||||
if (!article) return reply.code(404).send({ error: 'not found' });
|
if (!article) return reply.code(404).send({ error: 'not found' });
|
||||||
// 404 rather than 403 for a private article behind a paywall of sorts — an
|
|
||||||
// unauthenticated visitor shouldn't be able to tell the difference between
|
|
||||||
// "doesn't exist" and "exists but is private."
|
|
||||||
if (!hasPrivateAccess(req)) {
|
|
||||||
const privateNames = new Set(categoriesDb.listPrivateCategoryNames());
|
|
||||||
if (article.category.some((c) => privateNames.has(c))) {
|
|
||||||
return reply.code(404).send({ error: 'not found' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return article;
|
return article;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,12 +34,8 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories,
|
// Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories,
|
||||||
// per the "user may have no interest in Business or Culture" requirement. Private
|
// per the "user may have no interest in Business or Culture" requirement.
|
||||||
// categories are omitted entirely for anyone without a valid private-access cookie,
|
app.get('/api/categories', async () => {
|
||||||
// so they don't even show up as a nav tab to unlock.
|
return categoriesDb.listCategories();
|
||||||
app.get('/api/categories', async (req) => {
|
|
||||||
const categories = categoriesDb.listCategories();
|
|
||||||
if (hasPrivateAccess(req)) return categories;
|
|
||||||
return categories.filter((c) => !c.isPrivate);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import cookie from '@fastify/cookie';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { migrate } from './storage/db/index.js';
|
import { migrate } from './storage/db/index.js';
|
||||||
@@ -9,7 +8,6 @@ import { registerAuth } from './api/auth.js';
|
|||||||
import { registerPublicRoutes } from './api/public.js';
|
import { registerPublicRoutes } from './api/public.js';
|
||||||
import { registerAdminRoutes } from './api/admin.js';
|
import { registerAdminRoutes } from './api/admin.js';
|
||||||
import { registerMediaProxy } from './api/mediaProxy.js';
|
import { registerMediaProxy } from './api/mediaProxy.js';
|
||||||
import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js';
|
|
||||||
import { startScheduler } from './queue/scheduler.js';
|
import { startScheduler } from './queue/scheduler.js';
|
||||||
import { logger } from './storage/db/logs.js';
|
import { logger } from './storage/db/logs.js';
|
||||||
|
|
||||||
@@ -41,17 +39,11 @@ async function main() {
|
|||||||
// every PATCH (settings saves) and DELETE (removing sources/events) gets silently
|
// every PATCH (settings saves) and DELETE (removing sources/events) gets silently
|
||||||
// blocked by the browser at the CORS preflight stage, before the request ever
|
// blocked by the browser at the CORS preflight stage, before the request ever
|
||||||
// reaches a route handler.
|
// reaches a route handler.
|
||||||
// credentials: true is required for the browser to send/accept the private-category
|
|
||||||
// login cookie cross-origin — safe only because origin is a specific value above,
|
|
||||||
// never a wildcard (the two are mutually exclusive per the CORS spec anyway).
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: FRONTEND_ORIGIN,
|
origin: FRONTEND_ORIGIN,
|
||||||
credentials: true,
|
|
||||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
|
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(cookie);
|
|
||||||
|
|
||||||
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
|
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
|
||||||
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
|
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
|
||||||
// or POST with no payload) that still carries a Content-Type header — exactly what
|
// or POST with no payload) that still carries a Content-Type header — exactly what
|
||||||
@@ -69,7 +61,6 @@ async function main() {
|
|||||||
await registerAuth(app);
|
await registerAuth(app);
|
||||||
await registerPublicRoutes(app);
|
await registerPublicRoutes(app);
|
||||||
await registerAdminRoutes(app);
|
await registerAdminRoutes(app);
|
||||||
await registerPrivateAccess(app);
|
|
||||||
|
|
||||||
// Fastify's own logger is off (see below) — without this, an unhandled exception
|
// Fastify's own logger is off (see below) — without this, an unhandled exception
|
||||||
// in any route handler produces a bare 500 with zero trace anywhere, including the
|
// in any route handler produces a bare 500 with zero trace anywhere, including the
|
||||||
@@ -98,9 +89,6 @@ async function main() {
|
|||||||
|
|
||||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||||
logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`);
|
logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`);
|
||||||
if (!privateAccessConfigured()) {
|
|
||||||
logger.info('server', 'Private categories disabled — set PRIVATE_ACCESS_PASSWORD to enable');
|
|
||||||
}
|
|
||||||
|
|
||||||
startScheduler();
|
startScheduler();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { db } from './index.js';
|
import { db } from './index.js';
|
||||||
import type { MergedArticle } from './types.js';
|
import type { MergedArticle } from './types.js';
|
||||||
import { listPrivateCategoryNames } from './categories.js';
|
|
||||||
|
|
||||||
function rowToArticle(row: any): MergedArticle {
|
function rowToArticle(row: any): MergedArticle {
|
||||||
return {
|
return {
|
||||||
@@ -71,30 +70,17 @@ export function allArticlesNewestFirst(): MergedArticle[] {
|
|||||||
return rows.map(rowToArticle);
|
return rows.map(rowToArticle);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queryFeed(
|
export function queryFeed(filters: {
|
||||||
filters: {
|
|
||||||
category?: string;
|
category?: string;
|
||||||
geo?: string;
|
geo?: string;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
},
|
}): MergedArticle[] {
|
||||||
includePrivate = false
|
|
||||||
): MergedArticle[] {
|
|
||||||
let sql = 'SELECT * FROM merged_articles WHERE 1=1';
|
let sql = 'SELECT * FROM merged_articles WHERE 1=1';
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
// Without a valid private-access cookie, an article belonging to ANY private
|
|
||||||
// category is excluded entirely — including from a public category it's also
|
|
||||||
// tagged with, so a private source can't leak in sideways through a shared tag.
|
|
||||||
if (!includePrivate) {
|
|
||||||
for (const name of listPrivateCategoryNames()) {
|
|
||||||
sql += ' AND category NOT LIKE ?';
|
|
||||||
params.push(`%"${name}"%`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bare feed (no category/geo/eventId/tag — i.e. the homepage/"Top stories") only
|
// The bare feed (no category/geo/eventId/tag — i.e. the homepage/"Top stories") only
|
||||||
// shows articles whose contributing source(s) opted into "Push to Top Stories?" —
|
// shows articles whose contributing source(s) opted into "Push to Top Stories?" —
|
||||||
// otherwise every ingested article from every source would flood the homepage.
|
// otherwise every ingested article from every source would flood the homepage.
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ import { db } from './index.js';
|
|||||||
import type { Category } from './types.js';
|
import type { Category } from './types.js';
|
||||||
|
|
||||||
function rowToCategory(row: any): Category {
|
function rowToCategory(row: any): Category {
|
||||||
return {
|
return { id: row.id, name: row.name, priorityRank: row.priority_rank, isDefault: !!row.is_default };
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
priorityRank: row.priority_rank,
|
|
||||||
isDefault: !!row.is_default,
|
|
||||||
isPrivate: !!row.is_private
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCategories(): Category[] {
|
export function listCategories(): Category[] {
|
||||||
@@ -17,27 +11,16 @@ export function listCategories(): Category[] {
|
|||||||
return rows.map(rowToCategory);
|
return rows.map(rowToCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Names of every category marked private — used to filter articles/feed for unauthenticated visitors. */
|
export function setCategoryOrder(order: { id: string; priorityRank: number }[]) {
|
||||||
export function listPrivateCategoryNames(): string[] {
|
const stmt = db.prepare('UPDATE categories SET priority_rank = ? WHERE id = ?');
|
||||||
const rows = db.prepare('SELECT name FROM categories WHERE is_private = 1').all() as { name: string }[];
|
for (const c of order) stmt.run(c.priorityRank, c.id);
|
||||||
return rows.map((r) => r.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean }[]) {
|
export function createCategory(name: string): Category {
|
||||||
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ? WHERE id = ?');
|
|
||||||
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCategory(name: string, isPrivate = false): Category {
|
|
||||||
const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
|
const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
|
||||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number };
|
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number };
|
||||||
db.prepare('INSERT INTO categories (id, name, priority_rank, is_default, is_private) VALUES (?, ?, ?, 0, ?)').run(
|
db.prepare('INSERT INTO categories (id, name, priority_rank, is_default) VALUES (?, ?, ?, 0)').run(id, name, maxRank.m + 1);
|
||||||
id,
|
return { id, name, priorityRank: maxRank.m + 1, isDefault: false };
|
||||||
name,
|
|
||||||
maxRank.m + 1,
|
|
||||||
isPrivate ? 1 : 0
|
|
||||||
);
|
|
||||||
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCategory(id: string) {
|
export function deleteCategory(id: string) {
|
||||||
|
|||||||
@@ -139,8 +139,7 @@ export function migrate() {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
priority_rank INTEGER NOT NULL,
|
priority_rank INTEGER NOT NULL,
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
is_default INTEGER NOT NULL DEFAULT 0
|
||||||
is_private INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
@@ -204,9 +203,6 @@ export function migrate() {
|
|||||||
if (!hasColumn('global_settings', 'fxtwitter_base_url')) {
|
if (!hasColumn('global_settings', 'fxtwitter_base_url')) {
|
||||||
db.exec("ALTER TABLE global_settings ADD COLUMN fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'");
|
db.exec("ALTER TABLE global_settings ADD COLUMN fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'");
|
||||||
}
|
}
|
||||||
if (!hasColumn('categories', 'is_private')) {
|
|
||||||
db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
||||||
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
||||||
|
|||||||
@@ -116,8 +116,6 @@ export interface Category {
|
|||||||
name: string;
|
name: string;
|
||||||
priorityRank: number;
|
priorityRank: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
/** Hidden from /api/categories, /api/feed, and article detail for anyone without a valid private-access cookie. */
|
|
||||||
isPrivate: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalSettings {
|
export interface GlobalSettings {
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof f
|
|||||||
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
export const createCategory = (name: string, isPrivate = false, fetchFn?: typeof fetch) =>
|
export const createCategory = (name: string, fetchFn?: typeof fetch) =>
|
||||||
request<{ id: string; name: string; priorityRank: number; isDefault: boolean; isPrivate: boolean }>(
|
request<{ id: string; name: string; priorityRank: number; isDefault: boolean }>(
|
||||||
'/api/admin/categories',
|
'/api/admin/categories',
|
||||||
{ method: 'POST', body: JSON.stringify({ name, isPrivate }) },
|
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||||
fetchFn
|
fetchFn
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface CategoryPriority {
|
|||||||
name: string;
|
name: string;
|
||||||
priorityRank: number;
|
priorityRank: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
isPrivate: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSettings {
|
export interface AdminSettings {
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { getBackendUrl } from './config';
|
|||||||
import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types';
|
import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types';
|
||||||
|
|
||||||
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
||||||
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
|
const res = await fetchFn(`${getBackendUrl()}${path}`);
|
||||||
// is sent cross-origin to the backend, revealing private categories/articles to
|
|
||||||
// anyone who's logged in — without it every request would look unauthenticated.
|
|
||||||
const res = await fetchFn(`${getBackendUrl()}${path}`, { credentials: 'include' });
|
|
||||||
if (!res.ok) throw new Error(`Request failed: ${path} (${res.status})`);
|
if (!res.ok) throw new Error(`Request failed: ${path} (${res.status})`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { loginPrivateAccess } from '$lib/privateAccess';
|
|
||||||
|
|
||||||
let { onClose, onSuccess }: { onClose: () => void; onSuccess: () => void } = $props();
|
|
||||||
|
|
||||||
let password = $state('');
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!password || loading) return;
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
try {
|
|
||||||
await loginPrivateAccess(password);
|
|
||||||
onSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
error = (err as Error).message;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()} role="presentation">
|
|
||||||
<div
|
|
||||||
class="modal"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Private access login"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
<h2>Private access</h2>
|
|
||||||
<p class="hint">Enter the password to unlock private categories.</p>
|
|
||||||
<form
|
|
||||||
onsubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input type="password" placeholder="Password" bind:value={password} autofocus />
|
|
||||||
{#if error}
|
|
||||||
<p class="error">{error}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="secondary" onclick={onClose}>Cancel</button>
|
|
||||||
<button type="submit" disabled={loading || !password}>{loading ? 'Checking…' : 'Unlock'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
background: var(--surface-1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0 0 14px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-danger);
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 0.5px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
let saveTimer: ReturnType<typeof setTimeout>;
|
let saveTimer: ReturnType<typeof setTimeout>;
|
||||||
let newCategoryName = $state('');
|
let newCategoryName = $state('');
|
||||||
let newCategoryPrivate = $state(false);
|
|
||||||
let addingCategory = $state(false);
|
let addingCategory = $state(false);
|
||||||
|
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
@@ -40,20 +39,14 @@
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
addingCategory = true;
|
addingCategory = true;
|
||||||
try {
|
try {
|
||||||
const created = await createCategory(name, newCategoryPrivate);
|
const created = await createCategory(name);
|
||||||
local.categoryPriority = [...local.categoryPriority, created];
|
local.categoryPriority = [...local.categoryPriority, created];
|
||||||
newCategoryName = '';
|
newCategoryName = '';
|
||||||
newCategoryPrivate = false;
|
|
||||||
} finally {
|
} finally {
|
||||||
addingCategory = false;
|
addingCategory = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePrivate(id: string) {
|
|
||||||
local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, isPrivate: !c.isPrivate } : c));
|
|
||||||
scheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeCategory(id: string, isDefault: boolean, name: string) {
|
async function removeCategory(id: string, isDefault: boolean, name: string) {
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
// Sensible-default categories can still be removed — e.g. a fresh install's
|
// Sensible-default categories can still be removed — e.g. a fresh install's
|
||||||
@@ -151,21 +144,13 @@
|
|||||||
<p class="hint">
|
<p class="hint">
|
||||||
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
|
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
|
||||||
categories just wait longer when the queue is busy. This list also drives the site's nav —
|
categories just wait longer when the queue is busy. This list also drives the site's nav —
|
||||||
remove anything you're not interested in (Business, Culture, etc.) or add your own. A
|
remove anything you're not interested in (Business, Culture, etc.) or add your own.
|
||||||
private category (and everything in it) is hidden from the public site until a visitor
|
|
||||||
logs in with the lock icon in the masthead.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="priority-list">
|
<div class="priority-list">
|
||||||
{#each local.categoryPriority as cat, i (cat.id)}
|
{#each local.categoryPriority as cat, i (cat.id)}
|
||||||
<div class="priority-row">
|
<div class="priority-row">
|
||||||
<span class="rank">{i + 1}</span>
|
<span class="rank">{i + 1}</span>
|
||||||
<span class="name">{cat.name}</span>
|
<span class="name">{cat.name}</span>
|
||||||
{#if cat.name.toLowerCase() !== 'top stories'}
|
|
||||||
<label class="private-toggle">
|
|
||||||
<input type="checkbox" checked={cat.isPrivate} onchange={() => togglePrivate(cat.id)} />
|
|
||||||
Private
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
<button class="icon-btn" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Move up">▲</button>
|
<button class="icon-btn" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Move up">▲</button>
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -188,10 +173,6 @@
|
|||||||
bind:value={newCategoryName}
|
bind:value={newCategoryName}
|
||||||
onkeydown={(e) => e.key === 'Enter' && addCategory()}
|
onkeydown={(e) => e.key === 'Enter' && addCategory()}
|
||||||
/>
|
/>
|
||||||
<label class="private-toggle">
|
|
||||||
<input type="checkbox" bind:checked={newCategoryPrivate} />
|
|
||||||
Private
|
|
||||||
</label>
|
|
||||||
<button onclick={addCategory} disabled={addingCategory || !newCategoryName.trim()}>
|
<button onclick={addCategory} disabled={addingCategory || !newCategoryName.trim()}>
|
||||||
{addingCategory ? 'Adding…' : '+ Add'}
|
{addingCategory ? 'Adding…' : '+ Add'}
|
||||||
</button>
|
</button>
|
||||||
@@ -302,17 +283,6 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
.private-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.private-toggle input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.name {
|
.name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
// Client for the "private categories" cookie login (see backend/src/api/privateAccess.ts).
|
|
||||||
// Deliberately separate from adminAuth.ts/adminApi.ts: that's a header-based API key for
|
|
||||||
// the admin SPA only, while this is a plain cookie so an ordinary visitor's browser
|
|
||||||
// carries it across normal page loads with no localStorage/header plumbing needed.
|
|
||||||
|
|
||||||
import { getBackendUrl } from './config';
|
|
||||||
|
|
||||||
export interface PrivateAccessStatus {
|
|
||||||
authenticated: boolean;
|
|
||||||
configured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPrivateAccessStatus(fetchFn: typeof fetch = fetch): Promise<PrivateAccessStatus> {
|
|
||||||
const res = await fetchFn(`${getBackendUrl()}/api/private-access/status`, { credentials: 'include' });
|
|
||||||
if (!res.ok) return { authenticated: false, configured: false };
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws with a user-facing message on failure (wrong password, or the feature isn't configured). */
|
|
||||||
export async function loginPrivateAccess(password: string): Promise<void> {
|
|
||||||
const res = await fetch(`${getBackendUrl()}/api/private-access/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ password })
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error ?? `Login failed (${res.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logoutPrivateAccess(): Promise<void> {
|
|
||||||
await fetch(`${getBackendUrl()}/api/private-access/logout`, { method: 'POST', credentials: 'include' });
|
|
||||||
}
|
|
||||||
@@ -58,5 +58,4 @@ export interface Category {
|
|||||||
name: string;
|
name: string;
|
||||||
priorityRank: number;
|
priorityRank: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
isPrivate: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../lib/styles/app.css';
|
import '../lib/styles/app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import PrivateAccessModal from '$lib/components/PrivateAccessModal.svelte';
|
|
||||||
import { logoutPrivateAccess } from '$lib/privateAccess';
|
|
||||||
import { slugify } from '$lib/format';
|
import { slugify } from '$lib/format';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
let { children, data }: { children: any; data: LayoutData } = $props();
|
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||||
|
|
||||||
let showLoginModal = $state(false);
|
|
||||||
|
|
||||||
async function handleLockClick() {
|
|
||||||
if (data.privateAccess.authenticated) {
|
|
||||||
await logoutPrivateAccess();
|
|
||||||
await invalidateAll();
|
|
||||||
} else {
|
|
||||||
showLoginModal = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLoginSuccess() {
|
|
||||||
showLoginModal = false;
|
|
||||||
await invalidateAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Top stories" is a real Category row (it drives synthesis queue priority) but
|
// "Top stories" is a real Category row (it drives synthesis queue priority) but
|
||||||
// isn't itself a filterable category — it always means "everything, chronological",
|
// isn't itself a filterable category — it always means "everything, chronological",
|
||||||
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
|
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
|
||||||
@@ -56,26 +37,6 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{#if data.privateAccess?.configured}
|
|
||||||
<button
|
|
||||||
class="lock-btn"
|
|
||||||
onclick={handleLockClick}
|
|
||||||
aria-label={data.privateAccess.authenticated ? 'Log out of private categories' : 'Log in to private categories'}
|
|
||||||
title={data.privateAccess.authenticated ? 'Log out of private categories' : 'Log in to private categories'}
|
|
||||||
>
|
|
||||||
{#if data.privateAccess.authenticated}
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
||||||
<path d="M8 11V7a4 4 0 0 1 7.75-1.5" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
||||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if data.adminPanelEnabled}
|
{#if data.adminPanelEnabled}
|
||||||
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
|
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
@@ -90,10 +51,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showLoginModal}
|
|
||||||
<PrivateAccessModal onClose={() => (showLoginModal = false)} onSuccess={handleLoginSuccess} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
@@ -153,19 +110,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.lock-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.lock-btn:hover {
|
|
||||||
background: var(--surface-1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.cog {
|
.cog {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { getCategories } from '$lib/api';
|
import { getCategories } from '$lib/api';
|
||||||
import { getPrivateAccessStatus } from '$lib/privateAccess';
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, data }) => {
|
export const load: LayoutLoad = async ({ fetch, data }) => {
|
||||||
const [categories, privateAccess] = await Promise.all([getCategories(fetch), getPrivateAccessStatus(fetch)]);
|
const categories = await getCategories(fetch);
|
||||||
return { ...data, categories, privateAccess };
|
return { ...data, categories };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user