fix
This commit is contained in:
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user