This commit is contained in:
DioCrafts
2026-06-20 19:13:18 +02:00
parent e6ee5988ab
commit 6123843dd0
6 changed files with 262 additions and 15 deletions
+47
View File
@@ -22,6 +22,53 @@ async function mutate(url: string, method: string, body?: unknown): Promise<void
}
}
/** POST with no request body that returns a JSON payload (throws on non-2xx). */
async function postJson<T>(url: string): Promise<T> {
const res = await apiFetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() }
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(e.message || `POST ${url} failed: ${res.status}`);
}
return (await res.json()) as T;
}
// ── Maintenance ───────────────────────────────────────────────────────────
/** Outcome of a bulk metadata re-extraction run. */
export interface ReextractResult {
message: string;
total: number;
processed: number;
failed: number;
}
/** Re-scan every audio file and backfill its tag metadata (idempotent). */
export function reextractAudioMetadata(): Promise<ReextractResult> {
return postJson<ReextractResult>('/api/admin/audio/metadata/reextract');
}
/** Backfill EXIF / container capture dates for all media, re-bucketing the
* Photos timeline by real capture date (idempotent). */
export function reextractPhotoMetadata(): Promise<ReextractResult> {
return postJson<ReextractResult>('/api/admin/photos/metadata/reextract');
}
/** A freshly generated AES-256 at-rest blob-encryption key (base64) plus a
* data-loss warning authored by the server. */
export interface GeneratedKey {
key: string;
warning: string;
}
/** Generate a random AES-256 key for at-rest blob encryption. */
export function generateEncryptionKey(): Promise<GeneratedKey> {
return postJson<GeneratedKey>('/api/admin/settings/storage/generate-key');
}
// ── Users ───────────────────────────────────────────────────────────────
export interface AdminUsersPage {
@@ -338,11 +338,11 @@
/* Expand the grid track as soon as at least one owner cell is visible. */
.files-list-view:has(.owner-cell:not(.hidden)) {
--files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 72px;
--files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 200px;
}
.files-list-view {
--files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px;
--files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 200px;
display: flex;
flex-direction: column;
width: 100%;
+158 -10
View File
@@ -5,6 +5,7 @@
createUser,
deletePlugin,
deleteUser,
generateEncryptionKey,
getDashboard,
getMigration,
getOidcSettings,
@@ -16,6 +17,8 @@
listPlugins,
listUsers,
migrationAction,
reextractAudioMetadata,
reextractPhotoMetadata,
resetUserPassword,
saveOidc,
savePluginRetention,
@@ -30,6 +33,7 @@
testStorage,
verifyMigration,
type AdminDashboard,
type GeneratedKey,
type MigrationStatus,
type MigrationVerifyResult,
type OidcSettings,
@@ -37,6 +41,7 @@
type PluginInfo,
type PluginLogEntry,
type PluginRetention,
type ReextractResult,
type SmtpInfo,
type SmtpTestResult,
type StorageSettings,
@@ -629,6 +634,51 @@
errorToast(e);
}
// ── Maintenance: bulk metadata re-extraction ─────────────────────────────
let audioBusy = $state(false);
let audioResult = $state<ReextractResult | null>(null);
let photoBusy = $state(false);
let photoResult = $state<ReextractResult | null>(null);
async function runAudioReindex() {
audioBusy = true;
audioResult = null;
try {
audioResult = await reextractAudioMetadata();
} catch (e) {
reportError(e);
} finally {
audioBusy = false;
}
}
async function runPhotoReindex() {
photoBusy = true;
photoResult = null;
try {
photoResult = await reextractPhotoMetadata();
} catch (e) {
reportError(e);
} finally {
photoBusy = false;
}
}
// ── Storage: generate an at-rest encryption key ──────────────────────────
let keyBusy = $state(false);
let generatedKey = $state<GeneratedKey | null>(null);
async function runGenerateKey() {
keyBusy = true;
try {
generatedKey = await generateEncryptionKey();
} catch (e) {
reportError(e);
} finally {
keyBusy = false;
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
@@ -997,6 +1047,58 @@
{/if}
</div>
{/if}
<div class="card">
<h2>{t('admin.maintenance', 'Maintenance')}</h2>
<p class="muted">
{t(
'admin.maintenance_hint',
'Re-scan existing files to backfill metadata. Safe to re-run; processes the whole library and may take a while.'
)}
</p>
<div class="maint-row">
<button class="btn btn-secondary" disabled={audioBusy} onclick={runAudioReindex}>
<Icon name="music" />
{audioBusy
? t('admin.running', 'Running…')
: t('admin.reextract_audio', 'Re-extract audio metadata')}
</button>
{#if audioResult}
<span class="muted maint-result">
{t(
'admin.reextract_done',
{
processed: audioResult.processed,
total: audioResult.total,
failed: audioResult.failed
},
'{{processed}}/{{total}} processed · {{failed}} failed'
)}
</span>
{/if}
</div>
<div class="maint-row">
<button class="btn btn-secondary" disabled={photoBusy} onclick={runPhotoReindex}>
<Icon name="images" />
{photoBusy
? t('admin.running', 'Running…')
: t('admin.reextract_photos', 'Re-extract photo & video capture dates')}
</button>
{#if photoResult}
<span class="muted maint-result">
{t(
'admin.reextract_done',
{
processed: photoResult.processed,
total: photoResult.total,
failed: photoResult.failed
},
'{{processed}}/{{total}} processed · {{failed}} failed'
)}
</span>
{/if}
</div>
</div>
{/if}
{:else if tab === 'oidc'}
<div class="card">
@@ -1377,6 +1479,40 @@
{/if}
{/if}
</div>
<div class="card">
<h2>{t('admin.encryption', 'Encryption')}</h2>
<p class="muted">
{t(
'admin.encryption_hint',
'Generate an AES-256 key for at-rest blob encryption, then set it as OXICLOUD_STORAGE_ENCRYPTION_KEY in your server environment.'
)}
</p>
<button class="btn btn-secondary" disabled={keyBusy} onclick={runGenerateKey}>
<Icon name="key" />
{keyBusy ? t('admin.running', 'Running…') : t('admin.gen_key', 'Generate key')}
</button>
{#if generatedKey}
<p class="callback-row">
<code>{generatedKey.key}</code>
<button
type="button"
class="btn btn-sm btn-secondary"
onclick={() => copyText(generatedKey?.key ?? '')}
>
<Icon name="copy" />
{t('common.copy', 'Copy')}
</button>
</p>
<p class="alert alert--warn">
<Icon name="exclamation-triangle" />
{t(
'admin.gen_key_warning',
'Store this key securely. If it is lost, the encrypted data is irrecoverably lost.'
)}
</p>
{/if}
</div>
{:else if tab === 'smtp'}
<div class="card">
<h2>{t('admin.smtp_status', 'SMTP status')}</h2>
@@ -1990,7 +2126,7 @@
}
.log-level--error {
color: var(--color-danger-text);
color: var(--color-error-text);
}
.log-level--warn {
@@ -2067,7 +2203,7 @@
}
.ds-fill--danger {
background: var(--color-danger-text);
background: var(--color-error-text);
}
.kv {
@@ -2171,8 +2307,8 @@
}
.warn-card--danger {
border-color: var(--color-danger-text);
color: var(--color-danger-text);
border-color: var(--color-error-text);
color: var(--color-error-text);
}
/* Per-user storage-usage progress bar in the users table. */
@@ -2200,7 +2336,7 @@
}
.quota-fill--danger {
background: var(--color-danger-text);
background: var(--color-error-text);
}
.quota-input {
@@ -2232,7 +2368,7 @@
}
.icon-btn--danger {
color: var(--color-danger-text);
color: var(--color-error-text);
}
.icon-btn--success {
@@ -2282,8 +2418,8 @@
}
.discovery-result--fail {
border-color: var(--color-danger-text);
color: var(--color-danger-text);
border-color: var(--color-error-text);
color: var(--color-error-text);
}
.discovery-result .kv {
@@ -2298,6 +2434,18 @@
flex-wrap: wrap;
}
.maint-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-top: var(--space-3);
}
.maint-result {
font-variant-numeric: tabular-nums;
}
.install-bar {
display: flex;
align-items: center;
@@ -2499,7 +2647,7 @@
}
.status--error {
color: var(--color-danger-text);
color: var(--color-error-text);
}
.link-btn {
@@ -2511,6 +2659,6 @@
}
.link-btn--danger {
color: var(--color-danger-text);
color: var(--color-error-text);
}
</style>
+33 -1
View File
@@ -287,6 +287,18 @@
}
}
/**
* A tile thumbnail failed to load (no server thumbnail — e.g. SVGs, which the
* backend can't rasterise — or a transient error). Hide the broken <img> so
* the always-present placeholder shows through, and for videos kick off
* client-side frame extraction (which, on success, re-renders the tile from
* `videoThumbs`).
*/
function onThumbError(e: Event, photo: PhotoItem) {
(e.currentTarget as HTMLImageElement).style.display = 'none';
if (isVideo(photo)) void generateVideoThumb(photo);
}
// ── Client-side video thumbnail generation ──────────────────────────────
// When the server has no thumbnail for a video tile the <img> errors; we
// then extract a frame with the browser's native decoder and upload it.
@@ -522,6 +534,10 @@
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
<div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}>
<button class="photo-tile__open" onclick={() => onTileClick(photo)}>
<!-- Always-present placeholder: the thumbnail <img> overlays it and, when
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
this default rather than the browser's broken-image glyph. -->
<span class="photo-tile__placeholder" aria-hidden="true"><Icon name="file-image" /></span>
{#if videoThumbs[photo.id]}
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
{:else}
@@ -532,7 +548,7 @@
alt={photo.name}
loading="lazy"
decoding="async"
onerror={isVideo(photo) ? () => generateVideoThumb(photo) : undefined}
onerror={(e) => onThumbError(e, photo)}
/>
{/if}
{#if isVideo(photo)}
@@ -675,6 +691,7 @@
}
.photo-tile__open {
position: relative;
display: block;
width: 100%;
height: 100%;
@@ -684,7 +701,22 @@
background: none;
}
/* Default placeholder shown until the thumbnail paints over it (or when the
thumbnail can't load). Both this and the <img> are absolutely positioned so
DOM order — placeholder first, image second — keeps the image on top. */
.photo-tile__placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2rem;
color: var(--color-text-faint);
pointer-events: none;
}
.photo-tile__open img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
+11 -1
View File
@@ -1080,7 +1080,17 @@
"unchanged": "Leave blank to keep current",
"user": "User",
"username": "Username",
"users": "Users"
"users": "Users",
"running": "Running…",
"maintenance": "Maintenance",
"maintenance_hint": "Re-scan existing files to backfill metadata. Safe to re-run; processes the whole library and may take a while.",
"reextract_audio": "Re-extract audio metadata",
"reextract_photos": "Re-extract photo & video capture dates",
"reextract_done": "{{processed}}/{{total}} processed · {{failed}} failed",
"encryption": "Encryption",
"encryption_hint": "Generate an AES-256 key for at-rest blob encryption, then set it as OXICLOUD_STORAGE_ENCRYPTION_KEY in your server environment.",
"gen_key": "Generate key",
"gen_key_warning": "Store this key securely. If it is lost, the encrypted data is irrecoverably lost."
},
"profile": {
"page_title": "Profile",
+11 -1
View File
@@ -810,7 +810,17 @@
"title": "Admin",
"user": "Usuario",
"username": "Nombre de usuario",
"users": "Usuarios"
"users": "Usuarios",
"running": "Ejecutando…",
"maintenance": "Mantenimiento",
"maintenance_hint": "Reescanea los archivos existentes para rellenar metadatos. Se puede repetir sin riesgo; procesa toda la biblioteca y puede tardar.",
"reextract_audio": "Reextraer metadatos de audio",
"reextract_photos": "Reextraer fechas de fotos y vídeos",
"reextract_done": "{{processed}}/{{total}} procesados · {{failed}} fallidos",
"encryption": "Cifrado",
"encryption_hint": "Genera una clave AES-256 para el cifrado de blobs en reposo y configúrala como OXICLOUD_STORAGE_ENCRYPTION_KEY en el entorno del servidor.",
"gen_key": "Generar clave",
"gen_key_warning": "Guarda esta clave de forma segura. Si se pierde, los datos cifrados se pierden de forma irrecuperable."
},
"profile": {
"page_title": "Perfil",