Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2571c243c9 | ||
|
|
89f0d6a546 | ||
|
|
8bc9460989 |
44
ui/src/lib/paraglide/messages/admin_nav_import.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_import.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ImportInputs */
|
||||
|
||||
const en_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const id_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Import" |
|
||||
*
|
||||
* @param {Admin_Nav_ImportInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_import = /** @type {((inputs?: Admin_Nav_ImportInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ImportInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_import(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_import(inputs)
|
||||
if (locale === "id") return id_admin_nav_import(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_import(inputs)
|
||||
return fr_admin_nav_import(inputs)
|
||||
});
|
||||
@@ -2287,10 +2287,17 @@ export async function getUserStats(
|
||||
|
||||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
|
||||
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
|
||||
|
||||
/**
|
||||
* List all AI jobs from PocketBase, sorted by started descending.
|
||||
* No caching — admin views always want fresh data.
|
||||
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
|
||||
*/
|
||||
export async function listAIJobs(): Promise<AIJob[]> {
|
||||
return listAll<AIJob>('ai_jobs', '', '-started');
|
||||
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
|
||||
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
@@ -28,21 +28,6 @@
|
||||
label: () => m.admin_nav_image_gen(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/audio',
|
||||
label: () => m.admin_nav_audio(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/translation',
|
||||
label: () => m.admin_nav_translation(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/image-gen',
|
||||
label: () => m.admin_nav_image_gen(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/text-gen',
|
||||
label: () => m.admin_nav_text_gen(),
|
||||
|
||||
@@ -6,7 +6,8 @@ export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
// Stream jobs so navigation is instant; list populates a moment later.
|
||||
const jobs = listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AIJob } from '$lib/server/pocketbase';
|
||||
@@ -8,11 +7,11 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
|
||||
let jobs = $state<AIJob[]>([]);
|
||||
|
||||
// Keep in sync on server reloads
|
||||
// Resolve streamed promise on load and on server reloads (invalidateAll)
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
data.jobs.then((resolved) => { jobs = resolved; });
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
|
||||
@@ -18,23 +18,27 @@ const CACHE_KEY = 'admin:changelog:releases';
|
||||
const CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
// Return cached data synchronously (no streaming needed — already fast).
|
||||
const cached = await cache.get<Release[]>(CACHE_KEY);
|
||||
if (cached) {
|
||||
return { releases: cached };
|
||||
return { releases: cached, error: undefined as string | undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GITEA_RELEASES_URL, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { releases: [], error: `Gitea API returned ${res.status}` };
|
||||
// Cache miss: stream the external Gitea request so navigation isn't blocked.
|
||||
const releasesPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(GITEA_RELEASES_URL, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!res.ok) return [] as Release[];
|
||||
const releases: Release[] = await res.json();
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
|
||||
return filtered;
|
||||
} catch {
|
||||
return [] as Release[];
|
||||
}
|
||||
const releases: Release[] = await res.json();
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
|
||||
return { releases: filtered };
|
||||
} catch (e) {
|
||||
return { releases: [], error: String(e) };
|
||||
}
|
||||
})();
|
||||
|
||||
return { releases: releasesPromise, error: undefined as string | undefined };
|
||||
};
|
||||
|
||||
@@ -32,29 +32,33 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.error}
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
|
||||
{:else if data.releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each data.releases as release}
|
||||
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
|
||||
{#if release.name && release.name !== release.tag_name}
|
||||
<span class="text-sm text-(--color-text)">{release.name}</span>
|
||||
{#await data.releases}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">Loading releases…</p>
|
||||
{:then releases}
|
||||
{#if releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each releases as release}
|
||||
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
|
||||
{#if release.name && release.name !== release.tag_name}
|
||||
<span class="text-sm text-(--color-text)">{release.name}</span>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
|
||||
{/if}
|
||||
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
|
||||
</div>
|
||||
{#if release.body.trim()}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
|
||||
{/if}
|
||||
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
|
||||
</div>
|
||||
{#if release.body.trim()}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:catch}
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: 'Failed to load releases' })}</p>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
@@ -20,23 +20,29 @@ export interface BookSummary {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listImageModels<ImageModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
// Await models immediately — the page is unusable without them and the
|
||||
// backend returns this list instantly (in-memory, no I/O).
|
||||
// Books are streamed: the page renders at once and the book selector
|
||||
// populates a moment later without blocking navigation.
|
||||
const modelsResult = await listImageModels<ImageModelInfo>().catch((e) => {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(e) });
|
||||
return [] as ImageModelInfo[];
|
||||
});
|
||||
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
const booksPromise = listBooks()
|
||||
.then((all) =>
|
||||
all.map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
)
|
||||
.catch(() => [] as BookSummary[]);
|
||||
|
||||
return {
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
models: modelsResult,
|
||||
// Streamed — SvelteKit resolves this after the initial HTML is sent.
|
||||
books: booksPromise
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,8 +62,11 @@
|
||||
});
|
||||
|
||||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
// Books arrive as a streamed promise — start empty and populate on resolve.
|
||||
let books = $state<BookSummary[]>([]);
|
||||
$effect(() => {
|
||||
data.books.then((resolved) => { books = resolved; });
|
||||
});
|
||||
let slugInput = $state('');
|
||||
let slugFocused = $state(false);
|
||||
let selectedBook = $state<BookSummary | null>(null);
|
||||
|
||||
@@ -17,21 +17,23 @@ export interface TextModelInfo {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listTextModels<TextModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
// Await models immediately — in-memory list, no I/O, returns instantly.
|
||||
// Books are streamed so the page renders at once and the selector
|
||||
// populates a moment later without blocking navigation.
|
||||
const modelsResult = await listTextModels<TextModelInfo>().catch((e) => {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(e) });
|
||||
return [] as TextModelInfo[];
|
||||
});
|
||||
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
const booksPromise = listBooks()
|
||||
.then((all) =>
|
||||
all.map((b) => ({ slug: b.slug, title: b.title })) as BookSummary[]
|
||||
)
|
||||
.catch(() => [] as BookSummary[]);
|
||||
|
||||
return {
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title
|
||||
})) as BookSummary[]
|
||||
models: modelsResult,
|
||||
// Streamed — SvelteKit resolves this after the initial HTML is sent.
|
||||
books: booksPromise
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
// Server data is static per page load — intentional one-time snapshot.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const models: TextModelInfo[] = data.models ?? [];
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
// Books arrive as a streamed promise — start empty and populate on resolve.
|
||||
let books = $state<BookSummary[]>([]);
|
||||
$effect(() => {
|
||||
data.books.then((resolved) => { books = resolved; });
|
||||
});
|
||||
|
||||
// ── Config persistence ───────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_text_gen_config_v2';
|
||||
|
||||
@@ -17,22 +17,41 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
|
||||
/**
|
||||
* POST /api/admin/import
|
||||
* Create a new import task.
|
||||
* Create a new import task. Supports both multipart/form-data (file upload)
|
||||
* and application/json (object key reference).
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.json();
|
||||
const res = await backendFetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const ct = request.headers.get('content-type') ?? '';
|
||||
|
||||
let res: Response;
|
||||
|
||||
if (ct.includes('multipart/form-data')) {
|
||||
// Forward the raw FormData body; let the browser-set Content-Type
|
||||
// (which includes the boundary) pass through unchanged.
|
||||
const formData = await request.formData();
|
||||
res = await backendFetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
// Do NOT set Content-Type manually — the fetch API sets it
|
||||
// automatically with the correct boundary when given a FormData body.
|
||||
body: formData
|
||||
});
|
||||
} else {
|
||||
const body = await request.json();
|
||||
res = await backendFetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed to create import task' }));
|
||||
throw error(res.status, err.error || 'Failed to create import task');
|
||||
}
|
||||
const data = await res.json();
|
||||
return json(data);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user