Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ DB_NAME=skelpo
# 32-byte hex; generate with: openssl rand -hex 32
SESSION_SECRET=change_me_to_a_random_64_hex_value

# --- Admin UI ---
# Deployment-wide default language for /admin. Beats Accept-Language
# negotiation, loses to a per-user setting or the language-switcher cookie.
# Must be one of the supported admin locales (see src/admin/i18n/).
# ADMIN_DEFAULT_LOCALE=en

# --- Email backend (one of: log, smtp, resend, postmark, ses) ---
EMAIL_BACKEND=log
EMAIL_FROM=hello@example.com
Expand Down
57 changes: 45 additions & 12 deletions src/admin/contentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,8 @@ function skelpoRepeaterSerialize(wrap){
});
items.push(obj);
});
wrap.querySelector('input[type=hidden][name="f_"+wrap.dataset.name]')
const hidden=wrap.querySelector('input[type=hidden][name="f_'+wrap.dataset.name+'"]')
||wrap.querySelector('input[type=hidden]');
const hidden=wrap.querySelector('input[type=hidden]');
if(hidden)hidden.value=JSON.stringify(items);
}
function skelpoRepeaterRemove(card){
Expand Down Expand Up @@ -364,17 +363,41 @@ const Field: FC<{ def: FieldDef; value: string; t: Translator }> = ({ def, value
case 'select':
case 'multiselect': {
const opts = (def.validation?.options as string[] | undefined) ?? [];
if (def.type === 'select') {
return (
<div>
{label}
<select name={name}>
<option value="">—</option>
{opts.map((o) => (
<option value={o} selected={value === o}>
{o}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</option>
))}
</select>
</div>
);
}
// Multiselect renders as checkboxes (`name[]` → parseBody array); a
// <select multiple> needs ctrl-click and silently loses all but the
// last value in parseBody.
let selected: string[] = [];
try {
const p = JSON.parse(value);
if (Array.isArray(p)) selected = p.map(String);
} catch {
selected = value.split(',').map((s) => s.trim()).filter(Boolean);
}
return (
<div>
{label}
<select name={name} multiple={def.type === 'multiselect'}>
{def.type === 'select' ? <option value="">—</option> : null}
<div style="display:flex;flex-wrap:wrap;gap:8px 18px;padding:6px 2px">
{opts.map((o) => (
<option value={o} selected={value.includes(o)}>
{o}
</option>
<label style="display:flex;align-items:center;gap:6px;font-weight:400;margin:0;cursor:pointer">
<input type="checkbox" name={`${name}[]`} value={o} checked={selected.includes(o)} /> {o}
</label>
))}
</select>
</div>
</div>
);
}
Expand Down Expand Up @@ -663,7 +686,7 @@ export const ContentForm: FC<{
* by the type schema (booleans, numbers, JSON, relation id arrays).
*/
export function parseContentForm(
body: Record<string, string | File>,
body: Record<string, string | string[] | File>,
schema: FieldDef[],
): {
title: string;
Expand All @@ -679,7 +702,11 @@ export function parseContentForm(
const fields: Record<string, unknown> = {};
for (const def of schema) {
const raw = get(`f_${def.name}`);
if (raw === '' && !def.required) continue;
// Multiselect is never skipped: its checkboxes post under `f_<name>[]`,
// and an all-unchecked box submits *neither* key. Falling into the skip
// would drop the field from `fields` entirely (updateContent replaces the
// blob wholesale) instead of storing the `[]` the type promises.
if (raw === '' && !def.required && def.type !== 'multiselect') continue;
switch (def.type) {
case 'number':
fields[def.name] = raw === '' ? null : Number(raw);
Expand All @@ -705,9 +732,15 @@ export function parseContentForm(
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
break;
case 'multiselect':
fields[def.name] = raw ? raw.split(',').map((s) => s.trim()) : [];
case 'multiselect': {
// Checkbox form posts `f_<name>[]` (array, or string when hono sees
// a single value); the comma form remains for API/legacy posts.
const arr = body[`f_${def.name}[]`];
if (Array.isArray(arr)) fields[def.name] = arr.map(String);
else if (typeof arr === 'string') fields[def.name] = [arr];
else fields[def.name] = raw ? raw.split(',').map((s) => s.trim()) : [];
break;
}
default:
fields[def.name] = raw;
}
Expand Down
5 changes: 4 additions & 1 deletion src/admin/i18n/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ export async function attachAdminI18n(c: Context, next: Next): Promise<void | Re
const auth = c.get('auth');
const fromUser = auth ? coerceLocale(auth.user.locale) : null;
const fromCookie = coerceLocale(getCookie(c, ADMIN_LANG_COOKIE));
// Deployment-wide default (e.g. ADMIN_DEFAULT_LOCALE=de): beats browser
// negotiation, loses to an explicit per-user choice or cookie.
const fromEnv = coerceLocale(process.env.ADMIN_DEFAULT_LOCALE);
const locale: AdminLocale =
fromUser ?? fromCookie ?? negotiateLocale(c.req.header('accept-language'));
fromUser ?? fromCookie ?? fromEnv ?? negotiateLocale(c.req.header('accept-language'));

c.set('adminLocale', locale);
c.set('t', makeT(locale));
Expand Down
19 changes: 11 additions & 8 deletions src/admin/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ adminRoutes.get('/', async (c) => {
const flash = c.req.query('ok');

const recent = await query<{ id: number; title: string; typeSlug: string; status: string; updatedAt: unknown }>(
'SELECT `id`,`title`,`typeSlug`,`status`,`updatedAt` FROM `content` ORDER BY `updatedAt` DESC LIMIT 8',
"SELECT `id`,`title`,`typeSlug`,`status`,`updatedAt` FROM `content` WHERE `status` != 'archived' ORDER BY `updatedAt` DESC LIMIT 8",
);

return c.html(
Expand Down Expand Up @@ -356,7 +356,7 @@ adminRoutes.get('/', async (c) => {

// ── Maintenance: toggle + rotate preview token ─────────────────────────

import { setSetting, invalidateSettingsCache } from '../settings/store.js';
import { setSetting, invalidateSettingsCache, getDefaultLocale } from '../settings/store.js';

adminRoutes.post('/maintenance/toggle', async (c) => {
const auth = gate(c);
Expand Down Expand Up @@ -463,9 +463,10 @@ adminRoutes.get('/content/:type', async (c) => {
return c.html(<AdminPage title={t('common.forbidden')} t={t} user={{ ...auth.user, roleSlug: auth.role.slug }} caps={auth.role.capabilities}>{t('common.forbidden')}</AdminPage>, 403);
}

// Locale filter — defaults to en. "all" shows every locale (useful for
// diffing translation coverage). Available locales come from site.locales.
const sel = c.req.query('locale') ?? 'en';
// Locale filter — defaults to the site's default locale. "all" shows every
// locale (useful for diffing translation coverage). Available locales come
// from site.locales.
const sel = c.req.query('locale') ?? (await getDefaultLocale());
let availableLocales: string[] = ['en'];
try {
const sl = await queryOne<{ value: unknown }>(
Expand All @@ -482,7 +483,9 @@ adminRoutes.get('/content/:type', async (c) => {
const { rows: allRows } = await listContent({
typeSlug,
locale: sel === 'all' ? undefined : sel,
status: canDrafts ? ['draft', 'review', 'published', 'archived'] : ['published'],
// Archived (soft-deleted) rows are hidden everywhere in the admin; purge
// or restore them via the API (`?status=archived`).
status: canDrafts ? ['draft', 'review', 'published'] : ['published'],
includeDrafts: canDrafts,
limit: 200,
sort: '-updatedAt',
Expand Down Expand Up @@ -594,7 +597,7 @@ adminRoutes.get('/content/:type/new', async (c) => {
let availableLocales: string[] = [];
let defaultTitle = '';
let defaultSlug = '';
let defaultLocale = localeParam ?? 'en';
let defaultLocale = localeParam ?? (await getDefaultLocale());
let translationOf: number | undefined;

if (translationOfParam) {
Expand All @@ -605,7 +608,7 @@ adminRoutes.get('/content/:type/new', async (c) => {
defaultTitle = source.title;
defaultSlug = source.slug;
siblings = await query<{ id: number; locale: string; status: string }>(
'SELECT `id`, `locale`, `status` FROM `content` WHERE `translationGroupId` = ? AND `typeSlug` = ? ORDER BY `locale`',
"SELECT `id`, `locale`, `status` FROM `content` WHERE `translationGroupId` = ? AND `typeSlug` = ? AND `status` != 'archived' ORDER BY `locale`",
[source.translationGroupId, ct.slug],
);
availableLocales = await readSiteLocales();
Expand Down
2 changes: 2 additions & 0 deletions src/admin/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { can } from '../permissions/check.js';
import { getT, type Translator } from './i18n/index.js';
import { getAllSettings, setSetting, invalidateSettingsCache } from '../settings/store.js';
import { invalidate } from '../cache/deps.js';
import { fireEvent } from '../webhooks/dispatch.js';
import { execute, query, queryOne } from '../db/client.js';
import { normalizeDates } from '../db/datetime.js';
import { hashPassword } from '../auth/password.js';
Expand Down Expand Up @@ -355,6 +356,7 @@ adminScreens.post('/settings/:keyName', async (c) => {
invalidate([`setting:${keyName}`]);
invalidateSettingsCache();
invalidate(['settings:all']);
void fireEvent('setting.changed', { key: keyName }, [`setting:${keyName}`, 'settings:all']);
return c.redirect(`/admin/settings/${encodeURIComponent(keyName)}?ok=${encodeURIComponent(t('content.flashSaved'))}`, 302);
});

Expand Down
5 changes: 5 additions & 0 deletions src/routes/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Hono } from 'hono';
import { getAllSettings, getPublicSettings, getSetting, isSensitiveSettingKey, setSetting, invalidateSettingsCache } from '../../settings/store.js';
import { invalidate } from '../../cache/deps.js';
import { fireEvent } from '../../webhooks/dispatch.js';
import { withCache } from '../../cache/respond.js';
import { errorResponse } from './_helpers.js';
import { requireAuth, isResponse } from '../../auth/middleware.js';
Expand Down Expand Up @@ -68,6 +69,7 @@ settingsRoutes.put('/:key', async (c) => {
invalidateSettingsCache();
invalidate([`setting:${key}`]);
invalidate(['settings:all']);
void fireEvent('setting.changed', { key }, [`setting:${key}`, 'settings:all']);
return c.json({ data: { [key]: body.value } });
});

Expand All @@ -88,5 +90,8 @@ settingsRoutes.put('/', async (c) => {
invalidateSettingsCache();
invalidate(invalidated);
invalidate(['settings:all']);
for (const key of Object.keys(body)) {
void fireEvent('setting.changed', { key }, [`setting:${key}`, 'settings:all']);
}
return c.json({ data: body });
});
Loading