includio-cms 0.14.2 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@
12
12
  import Transform from '@tabler/icons-svelte/icons/transform';
13
13
  import Database from '@tabler/icons-svelte/icons/database';
14
14
  import Server from '@tabler/icons-svelte/icons/server';
15
+ import Clock from '@tabler/icons-svelte/icons/clock';
16
+ import Settings from '@tabler/icons-svelte/icons/automation';
15
17
  import Button from '../../../components/ui/button/button.svelte';
16
18
  import * as Card from '../../../components/ui/card/index.js';
17
19
  import { toast } from 'svelte-sonner';
@@ -56,6 +58,13 @@
56
58
  systemInfo: SystemInfo;
57
59
  }
58
60
 
61
+ interface MaintenanceStatus {
62
+ running: boolean;
63
+ lastRun: string | null;
64
+ nextRun: string | null;
65
+ lastResult: { stylesCreated: number; postersCreated: number; transcodesCreated: number; orphansDeleted: number } | null;
66
+ }
67
+
59
68
  function formatBytes(bytes: number): string {
60
69
  if (bytes === 0) return '0 B';
61
70
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -63,8 +72,28 @@
63
72
  return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
64
73
  }
65
74
 
75
+ function timeAgo(dateStr: string): string {
76
+ const diff = Date.now() - new Date(dateStr).getTime();
77
+ const mins = Math.floor(diff / 60000);
78
+ if (mins < 1) return 'przed chwilą';
79
+ if (mins < 60) return `${mins} min temu`;
80
+ const hours = Math.floor(mins / 60);
81
+ if (hours < 24) return `${hours}h temu`;
82
+ return `${Math.floor(hours / 24)}d temu`;
83
+ }
84
+
85
+ function timeUntil(dateStr: string): string {
86
+ const diff = new Date(dateStr).getTime() - Date.now();
87
+ const mins = Math.floor(diff / 60000);
88
+ if (mins < 1) return 'za chwilę';
89
+ if (mins < 60) return `za ${mins} min`;
90
+ const hours = Math.floor(mins / 60);
91
+ return `za ${hours}h`;
92
+ }
93
+
66
94
  let report = $state<GcReport | null>(null);
67
95
  let sysInfo = $state<SystemInfoResponse | null>(null);
96
+ let maintenanceStatus = $state<MaintenanceStatus | null>(null);
68
97
  let loading = $state(true);
69
98
  let purging = $state(false);
70
99
  let cleaningOrphans = $state(false);
@@ -110,18 +139,35 @@
110
139
  report ? report.videoStylesExpected - report.videoStylesDone : 0
111
140
  );
112
141
 
142
+ let diskSegments = $derived(
143
+ sysInfo
144
+ ? [
145
+ { bytes: sysInfo.diskUsage.originals.images, color: 'var(--primary)', label: 'Obrazy' },
146
+ { bytes: sysInfo.diskUsage.originals.videos, color: '#8B5CF6', label: 'Wideo' },
147
+ { bytes: sysInfo.diskUsage.originals.audio + sysInfo.diskUsage.originals.pdf + sysInfo.diskUsage.originals.other, color: '#6B7280', label: 'Inne' },
148
+ { bytes: sysInfo.diskUsage.imageStyles.bytes, color: '#3B82F6', label: 'Style obrazów' },
149
+ { bytes: sysInfo.diskUsage.videoStyles.bytes, color: '#A855F7', label: 'Style wideo' },
150
+ { bytes: sysInfo.diskUsage.posters.bytes, color: '#EC4899', label: 'Postery' }
151
+ ].filter((s) => s.bytes > 0)
152
+ : []
153
+ );
154
+
113
155
  async function loadReport() {
114
156
  loading = true;
115
157
  try {
116
- const [gcRes, sysRes] = await Promise.all([
158
+ const [gcRes, sysRes, msRes] = await Promise.all([
117
159
  fetch('/admin/api/media-gc'),
118
- fetch('/admin/api/system-info')
160
+ fetch('/admin/api/system-info'),
161
+ fetch('/admin/api/maintenance-status')
119
162
  ]);
120
163
  if (!gcRes.ok) throw new Error('Failed to load GC report');
121
164
  report = await gcRes.json();
122
165
  if (sysRes.ok) {
123
166
  sysInfo = await sysRes.json();
124
167
  }
168
+ if (msRes.ok) {
169
+ maintenanceStatus = await msRes.json();
170
+ }
125
171
  } catch {
126
172
  toast.error('Nie udało się załadować raportu');
127
173
  } finally {
@@ -159,60 +205,55 @@
159
205
  }
160
206
  }
161
207
 
208
+ async function handleSSE(
209
+ url: string,
210
+ abort: AbortController,
211
+ onEvent: (event: Record<string, unknown>) => void
212
+ ) {
213
+ const res = await fetch(url, { method: 'POST', signal: abort.signal });
214
+ if (!res.ok) throw new Error('Failed to start');
215
+ if (!res.body) throw new Error('No response body');
216
+
217
+ const reader = res.body.getReader();
218
+ const decoder = new TextDecoder();
219
+ let buffer = '';
220
+
221
+ while (true) {
222
+ const { done, value } = await reader.read();
223
+ if (done) break;
224
+
225
+ buffer += decoder.decode(value, { stream: true });
226
+ const chunks = buffer.split('\n\n');
227
+ buffer = chunks.pop() || '';
228
+
229
+ for (const chunk of chunks) {
230
+ if (!chunk.startsWith('data: ')) continue;
231
+ onEvent(JSON.parse(chunk.slice(6)));
232
+ }
233
+ }
234
+ }
235
+
162
236
  async function startBatchGenerate() {
163
237
  generating = true;
164
- genTotal = 0;
165
- genProcessed = 0;
166
- genCreated = 0;
167
- genSkipped = 0;
168
- genCurrentFile = '';
169
- genErrors = 0;
238
+ genTotal = 0; genProcessed = 0; genCreated = 0; genSkipped = 0; genCurrentFile = ''; genErrors = 0;
170
239
  genAbort = new AbortController();
171
240
 
172
241
  try {
173
- const res = await fetch('/admin/api/generate-styles', {
174
- method: 'POST',
175
- signal: genAbort.signal
176
- });
177
-
178
- if (!res.ok) throw new Error('Failed to start');
179
- if (!res.body) throw new Error('No response body');
180
-
181
- const reader = res.body.getReader();
182
- const decoder = new TextDecoder();
183
- let buffer = '';
184
-
185
- while (true) {
186
- const { done, value } = await reader.read();
187
- if (done) break;
188
-
189
- buffer += decoder.decode(value, { stream: true });
190
- const chunks = buffer.split('\n\n');
191
- buffer = chunks.pop() || '';
192
-
193
- for (const chunk of chunks) {
194
- if (!chunk.startsWith('data: ')) continue;
195
- const event = JSON.parse(chunk.slice(6));
196
-
197
- genTotal = event.total ?? genTotal;
198
- genProcessed = event.processed ?? genProcessed;
199
- genCreated = event.created ?? genCreated;
200
- genSkipped = event.skipped ?? genSkipped;
201
- genCurrentFile = event.currentFile ?? genCurrentFile;
202
-
203
- if (event.type === 'error') {
204
- genErrors++;
205
- }
206
-
207
- if (event.type === 'done') {
208
- const parts = [`Przetworzono ${genTotal} obrazów`];
209
- if (genCreated > 0) parts.push(`utworzono ${genCreated} styli`);
210
- if (genSkipped > 0) parts.push(`pominięto ${genSkipped} (już istnieją)`);
211
- if (genErrors > 0) parts.push(`${genErrors} błędów`);
212
- toast.success(parts.join(', '));
213
- }
242
+ await handleSSE('/admin/api/generate-styles', genAbort, (event) => {
243
+ genTotal = (event.total as number) ?? genTotal;
244
+ genProcessed = (event.processed as number) ?? genProcessed;
245
+ genCreated = (event.created as number) ?? genCreated;
246
+ genSkipped = (event.skipped as number) ?? genSkipped;
247
+ genCurrentFile = (event.currentFile as string) ?? genCurrentFile;
248
+ if (event.type === 'error') genErrors++;
249
+ if (event.type === 'done') {
250
+ const parts = [`Przetworzono ${genTotal} obrazów`];
251
+ if (genCreated > 0) parts.push(`utworzono ${genCreated} styli`);
252
+ if (genSkipped > 0) parts.push(`pominięto ${genSkipped} (już istnieją)`);
253
+ if (genErrors > 0) parts.push(`${genErrors} błędów`);
254
+ toast.success(parts.join(', '));
214
255
  }
215
- }
256
+ });
216
257
  } catch (e) {
217
258
  if (e instanceof DOMException && e.name === 'AbortError') {
218
259
  toast.info(`Przerwano po ${genProcessed}/${genTotal} obrazów`);
@@ -226,64 +267,27 @@
226
267
  }
227
268
  }
228
269
 
229
- function cancelBatchGenerate() {
230
- genAbort?.abort();
231
- }
232
-
233
270
  async function startPosterGenerate() {
234
271
  posterGenerating = true;
235
- posterTotal = 0;
236
- posterProcessed = 0;
237
- posterCreated = 0;
238
- posterSkipped = 0;
239
- posterCurrentFile = '';
240
- posterErrors = 0;
272
+ posterTotal = 0; posterProcessed = 0; posterCreated = 0; posterSkipped = 0; posterCurrentFile = ''; posterErrors = 0;
241
273
  posterAbort = new AbortController();
242
274
 
243
275
  try {
244
- const res = await fetch('/admin/api/regenerate-posters', {
245
- method: 'POST',
246
- signal: posterAbort.signal
247
- });
248
-
249
- if (!res.ok) throw new Error('Failed to start');
250
- if (!res.body) throw new Error('No response body');
251
-
252
- const reader = res.body.getReader();
253
- const decoder = new TextDecoder();
254
- let buffer = '';
255
-
256
- while (true) {
257
- const { done, value } = await reader.read();
258
- if (done) break;
259
-
260
- buffer += decoder.decode(value, { stream: true });
261
- const chunks = buffer.split('\n\n');
262
- buffer = chunks.pop() || '';
263
-
264
- for (const chunk of chunks) {
265
- if (!chunk.startsWith('data: ')) continue;
266
- const event = JSON.parse(chunk.slice(6));
267
-
268
- posterTotal = event.total ?? posterTotal;
269
- posterProcessed = event.processed ?? posterProcessed;
270
- posterCreated = event.created ?? posterCreated;
271
- posterSkipped = event.skipped ?? posterSkipped;
272
- posterCurrentFile = event.currentFile ?? posterCurrentFile;
273
-
274
- if (event.type === 'error') {
275
- posterErrors++;
276
- }
277
-
278
- if (event.type === 'done') {
279
- const parts = [`Przetworzono ${posterTotal} filmów`];
280
- if (posterCreated > 0) parts.push(`utworzono ${posterCreated} posterów`);
281
- if (posterSkipped > 0) parts.push(`pominięto ${posterSkipped} (już istnieją)`);
282
- if (posterErrors > 0) parts.push(`${posterErrors} błędów`);
283
- toast.success(parts.join(', '));
284
- }
276
+ await handleSSE('/admin/api/regenerate-posters', posterAbort, (event) => {
277
+ posterTotal = (event.total as number) ?? posterTotal;
278
+ posterProcessed = (event.processed as number) ?? posterProcessed;
279
+ posterCreated = (event.created as number) ?? posterCreated;
280
+ posterSkipped = (event.skipped as number) ?? posterSkipped;
281
+ posterCurrentFile = (event.currentFile as string) ?? posterCurrentFile;
282
+ if (event.type === 'error') posterErrors++;
283
+ if (event.type === 'done') {
284
+ const parts = [`Przetworzono ${posterTotal} filmów`];
285
+ if (posterCreated > 0) parts.push(`utworzono ${posterCreated} posterów`);
286
+ if (posterSkipped > 0) parts.push(`pominięto ${posterSkipped} (już istnieją)`);
287
+ if (posterErrors > 0) parts.push(`${posterErrors} błędów`);
288
+ toast.success(parts.join(', '));
285
289
  }
286
- }
290
+ });
287
291
  } catch (e) {
288
292
  if (e instanceof DOMException && e.name === 'AbortError') {
289
293
  toast.info(`Przerwano po ${posterProcessed}/${posterTotal} filmów`);
@@ -297,64 +301,27 @@
297
301
  }
298
302
  }
299
303
 
300
- function cancelPosterGenerate() {
301
- posterAbort?.abort();
302
- }
303
-
304
304
  async function startTranscode() {
305
305
  transcoding = true;
306
- transTotal = 0;
307
- transProcessed = 0;
308
- transCreated = 0;
309
- transSkipped = 0;
310
- transCurrentFile = '';
311
- transErrors = 0;
306
+ transTotal = 0; transProcessed = 0; transCreated = 0; transSkipped = 0; transCurrentFile = ''; transErrors = 0;
312
307
  transAbort = new AbortController();
313
308
 
314
309
  try {
315
- const res = await fetch('/admin/api/transcode-videos', {
316
- method: 'POST',
317
- signal: transAbort.signal
318
- });
319
-
320
- if (!res.ok) throw new Error('Failed to start');
321
- if (!res.body) throw new Error('No response body');
322
-
323
- const reader = res.body.getReader();
324
- const decoder = new TextDecoder();
325
- let buffer = '';
326
-
327
- while (true) {
328
- const { done, value } = await reader.read();
329
- if (done) break;
330
-
331
- buffer += decoder.decode(value, { stream: true });
332
- const chunks = buffer.split('\n\n');
333
- buffer = chunks.pop() || '';
334
-
335
- for (const chunk of chunks) {
336
- if (!chunk.startsWith('data: ')) continue;
337
- const event = JSON.parse(chunk.slice(6));
338
-
339
- transTotal = event.total ?? transTotal;
340
- transProcessed = event.processed ?? transProcessed;
341
- transCreated = event.created ?? transCreated;
342
- transSkipped = event.skipped ?? transSkipped;
343
- transCurrentFile = event.currentFile ?? transCurrentFile;
344
-
345
- if (event.type === 'error') {
346
- transErrors++;
347
- }
348
-
349
- if (event.type === 'done') {
350
- const parts = [`Przetworzono ${transTotal} wariantów`];
351
- if (transCreated > 0) parts.push(`utworzono ${transCreated}`);
352
- if (transSkipped > 0) parts.push(`pominięto ${transSkipped}`);
353
- if (transErrors > 0) parts.push(`${transErrors} błędów`);
354
- toast.success(parts.join(', '));
355
- }
310
+ await handleSSE('/admin/api/transcode-videos', transAbort, (event) => {
311
+ transTotal = (event.total as number) ?? transTotal;
312
+ transProcessed = (event.processed as number) ?? transProcessed;
313
+ transCreated = (event.created as number) ?? transCreated;
314
+ transSkipped = (event.skipped as number) ?? transSkipped;
315
+ transCurrentFile = (event.currentFile as string) ?? transCurrentFile;
316
+ if (event.type === 'error') transErrors++;
317
+ if (event.type === 'done') {
318
+ const parts = [`Przetworzono ${transTotal} wariantów`];
319
+ if (transCreated > 0) parts.push(`utworzono ${transCreated}`);
320
+ if (transSkipped > 0) parts.push(`pominięto ${transSkipped}`);
321
+ if (transErrors > 0) parts.push(`${transErrors} błędów`);
322
+ toast.success(parts.join(', '));
356
323
  }
357
- }
324
+ });
358
325
  } catch (e) {
359
326
  if (e instanceof DOMException && e.name === 'AbortError') {
360
327
  toast.info(`Przerwano po ${transProcessed}/${transTotal} wariantów`);
@@ -368,10 +335,6 @@
368
335
  }
369
336
  }
370
337
 
371
- function cancelTranscode() {
372
- transAbort?.abort();
373
- }
374
-
375
338
  async function purgeVideoStylesAction() {
376
339
  purgingVideoStyles = true;
377
340
  try {
@@ -393,508 +356,451 @@
393
356
  </script>
394
357
 
395
358
  <div class="p-5 pb-24 md:p-7">
396
- <div class="mb-6">
397
- <h1 class="mb-1 text-2xl font-bold">Konserwacja</h1>
398
- <p class="text-sm" style="color: var(--muted-foreground);">
399
- Narzędzia do zarządzania plikami mediów i stylami obrazów
400
- </p>
359
+ <!-- Header -->
360
+ <div class="mb-6 flex items-start justify-between">
361
+ <div>
362
+ <h1 class="mb-1 text-2xl font-bold">Konserwacja</h1>
363
+ <p class="text-sm" style="color: var(--muted-foreground);">
364
+ Zarządzanie mediami, optymalizacja i automatyczna konserwacja
365
+ </p>
366
+ </div>
367
+ <Button variant="outline" size="sm" onclick={loadReport} disabled={loading}>
368
+ {#if loading}
369
+ <Loader2 class="size-4 animate-spin" />
370
+ {:else}
371
+ <Refresh class="size-4" />
372
+ {/if}
373
+ Odśwież
374
+ </Button>
401
375
  </div>
402
376
 
377
+ <!-- Auto-maintenance status -->
378
+ {#if maintenanceStatus}
379
+ <div
380
+ class="mb-6 flex items-center gap-3 rounded-lg border px-4 py-3 text-sm"
381
+ style="border-color: var(--border); background: var(--muted, #f9fafb);"
382
+ >
383
+ <Settings class="size-4 shrink-0" style="color: var(--primary);" />
384
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
385
+ <span class="font-medium">Automatyczna konserwacja</span>
386
+ {#if maintenanceStatus.running}
387
+ <span class="flex items-center gap-1.5" style="color: var(--primary);">
388
+ <Loader2 class="size-3.5 animate-spin" />
389
+ W trakcie...
390
+ </span>
391
+ {:else if maintenanceStatus.lastRun}
392
+ <span style="color: var(--muted-foreground);">
393
+ Ostatnio: {timeAgo(maintenanceStatus.lastRun)}
394
+ </span>
395
+ {/if}
396
+ {#if maintenanceStatus.nextRun && !maintenanceStatus.running}
397
+ <span style="color: var(--muted-foreground);">
398
+ <Clock class="mr-0.5 inline size-3.5" style="vertical-align: -2px;" />
399
+ Następnie: {timeUntil(maintenanceStatus.nextRun)}
400
+ </span>
401
+ {/if}
402
+ {#if maintenanceStatus.lastResult}
403
+ {@const r = maintenanceStatus.lastResult}
404
+ {#if r.stylesCreated + r.postersCreated + r.transcodesCreated + r.orphansDeleted > 0}
405
+ <span style="color: var(--muted-foreground);">
406
+ Ostatni wynik:
407
+ {[
408
+ r.stylesCreated > 0 ? `${r.stylesCreated} styli` : '',
409
+ r.postersCreated > 0 ? `${r.postersCreated} posterów` : '',
410
+ r.transcodesCreated > 0 ? `${r.transcodesCreated} transkodowań` : '',
411
+ r.orphansDeleted > 0 ? `${r.orphansDeleted} osieroconych` : ''
412
+ ].filter(Boolean).join(', ')}
413
+ </span>
414
+ {/if}
415
+ {/if}
416
+ </div>
417
+ </div>
418
+ {/if}
419
+
403
420
  {#if loading}
404
421
  <div class="flex items-center justify-center py-20">
405
422
  <Loader2 class="size-6 animate-spin" style="color: var(--muted-foreground);" />
406
423
  </div>
407
424
  {:else if report}
408
- <div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
409
- <!-- Image styles -->
410
- <Card.Root>
411
- <Card.Header>
412
- <div class="flex items-center gap-2">
413
- <Photo class="size-5" style="color: var(--primary);" />
414
- <Card.Title>Style obrazów</Card.Title>
415
- </div>
416
- <Card.Description>
417
- Wygenerowane warianty (mniejsze rozmiary, WebP/AVIF)
418
- </Card.Description>
419
- </Card.Header>
420
- <Card.Content>
421
- <p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
422
- {report.imageStylesCount}
423
- <span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.expectedStylesCount}</span>
424
- </p>
425
- <p class="mb-4 text-xs" style="color: var(--muted-foreground);">
426
- {report.processableImagesCount} obrazów, {report.expectedStylesCount} oczekiwanych styli
427
- </p>
428
-
429
- <!-- Batch generate -->
430
- {#if generating}
431
- <div class="mb-4">
432
- <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
433
- <span>{genProcessed}/{genTotal} obrazów</span>
434
- <span>{genPercent}%</span>
435
- </div>
436
- <div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
437
- <div
438
- class="h-full rounded-full transition-all duration-300"
439
- style="width: {genPercent}%; background: var(--primary);"
440
- ></div>
425
+ <!-- Section: Media Processing -->
426
+ <div class="mb-8">
427
+ <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
428
+ Przetwarzanie mediów
429
+ </h2>
430
+ <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
431
+ <!-- Image styles -->
432
+ <Card.Root>
433
+ <Card.Header class="pb-3">
434
+ <div class="flex items-center justify-between">
435
+ <div class="flex items-center gap-2">
436
+ <Photo class="size-5" style="color: var(--primary);" />
437
+ <Card.Title class="text-base">Style obrazów</Card.Title>
441
438
  </div>
442
- <p class="mt-1 text-xs" style="color: var(--muted-foreground);">
443
- Utworzono: {genCreated}, pominięto: {genSkipped}{genErrors > 0 ? `, błędów: ${genErrors}` : ''}
444
- </p>
445
- <p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
446
- {genCurrentFile}
447
- </p>
448
- <Button
449
- variant="outline"
450
- size="sm"
451
- onclick={cancelBatchGenerate}
452
- class="mt-2"
453
- >
454
- <PlayerStop class="size-4" />
455
- Anuluj
456
- </Button>
457
- </div>
458
- {:else if report.missingStylesCount > 0}
459
- <div class="mb-3">
460
- <p class="mb-2 text-sm" style="color: var(--warning, #C4893A);">
461
- Brakuje {report.missingStylesCount} styli — generowanie odbywa się przy wyświetleniu, co może spowalniać aplikację.
462
- </p>
463
- <Button
464
- variant="default"
465
- size="sm"
466
- onclick={startBatchGenerate}
467
- >
468
- <PlayerPlay class="size-4" />
469
- Generuj brakujące style
470
- </Button>
439
+ {#if report.missingStylesCount === 0}
440
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--success, #3A8A5C) 15%, transparent); color: var(--success, #3A8A5C);">OK</span>
441
+ {:else}
442
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--warning, #C4893A) 15%, transparent); color: var(--warning, #C4893A);">Brakuje {report.missingStylesCount}</span>
443
+ {/if}
471
444
  </div>
472
- {:else}
473
- <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
474
- <CircleCheck class="size-4" />
475
- Wszystkie style wygenerowane
445
+ </Card.Header>
446
+ <Card.Content>
447
+ <p class="text-sm" style="color: var(--muted-foreground);">
448
+ Warianty (WebP, AVIF, responsywne rozmiary)
449
+ </p>
450
+ <div class="mt-3 flex items-baseline gap-1.5">
451
+ <span class="text-2xl font-bold">{report.imageStylesCount}</span>
452
+ <span class="text-sm" style="color: var(--muted-foreground);">styli</span>
476
453
  </div>
477
- {/if}
454
+ <p class="mt-0.5 text-xs" style="color: var(--muted-foreground);">
455
+ {report.processableImagesCount} obrazów{report.missingStylesCount > 0 ? `, brakuje ${report.missingStylesCount} domyślnych` : ''}
456
+ </p>
478
457
 
479
- <Button
480
- variant="destructive"
481
- size="sm"
482
- onclick={purgeStyles}
483
- disabled={purging || generating || report.imageStylesCount === 0}
484
- >
485
- {#if purging}
486
- <Loader2 class="size-4 animate-spin" />
458
+ {#if generating}
459
+ <div class="mt-3">
460
+ <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
461
+ <span>{genProcessed}/{genTotal} obrazów</span>
462
+ <span>{genPercent}%</span>
463
+ </div>
464
+ <div class="h-1.5 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
465
+ <div class="h-full rounded-full transition-all duration-300" style="width: {genPercent}%; background: var(--primary);"></div>
466
+ </div>
467
+ <p class="mt-1 truncate text-xs" style="color: var(--muted-foreground);">{genCurrentFile}</p>
468
+ <Button variant="outline" size="sm" onclick={() => genAbort?.abort()} class="mt-2">
469
+ <PlayerStop class="size-3.5" /> Anuluj
470
+ </Button>
471
+ </div>
487
472
  {:else}
488
- <Trash class="size-4" />
489
- {/if}
490
- Usuń wszystkie i regeneruj
491
- </Button>
492
- <p class="mt-2 text-xs" style="color: var(--text-light);">
493
- Style zostaną odtworzone automatycznie przy kolejnym wyświetleniu
494
- </p>
495
- </Card.Content>
496
- </Card.Root>
497
-
498
- <!-- Video posters -->
499
- <Card.Root>
500
- <Card.Header>
501
- <div class="flex items-center gap-2">
502
- <Video class="size-5" style="color: var(--primary);" />
503
- <Card.Title>Postery video</Card.Title>
504
- </div>
505
- <Card.Description>
506
- Miniaturki i postery generowane z plików wideo (ffmpeg)
507
- </Card.Description>
508
- </Card.Header>
509
- <Card.Content>
510
- <p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
511
- {report.videosWithPosters}
512
- <span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.videosCount}</span>
513
- </p>
514
- <p class="mb-4 text-xs" style="color: var(--muted-foreground);">
515
- {report.videosCount} filmów, {report.videosMissingPosters} bez posterów
516
- </p>
517
-
518
- {#if posterGenerating}
519
- <div class="mb-4">
520
- <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
521
- <span>{posterProcessed}/{posterTotal} filmów</span>
522
- <span>{posterPercent}%</span>
473
+ <div class="mt-3 flex flex-wrap gap-2">
474
+ {#if report.missingStylesCount > 0}
475
+ <Button variant="default" size="sm" onclick={startBatchGenerate}>
476
+ <PlayerPlay class="size-3.5" /> Generuj brakujące
477
+ </Button>
478
+ {/if}
479
+ {#if report.imageStylesCount > 0}
480
+ <Button variant="outline" size="sm" onclick={purgeStyles} disabled={purging || generating}>
481
+ {#if purging}<Loader2 class="size-3.5 animate-spin" />{:else}<Trash class="size-3.5" />{/if}
482
+ Regeneruj wszystkie
483
+ </Button>
484
+ {/if}
523
485
  </div>
524
- <div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
525
- <div
526
- class="h-full rounded-full transition-all duration-300"
527
- style="width: {posterPercent}%; background: var(--primary);"
528
- ></div>
486
+ {/if}
487
+ </Card.Content>
488
+ </Card.Root>
489
+
490
+ <!-- Video posters -->
491
+ <Card.Root>
492
+ <Card.Header class="pb-3">
493
+ <div class="flex items-center justify-between">
494
+ <div class="flex items-center gap-2">
495
+ <Video class="size-5" style="color: var(--primary);" />
496
+ <Card.Title class="text-base">Postery wideo</Card.Title>
529
497
  </div>
530
- <p class="mt-1 text-xs" style="color: var(--muted-foreground);">
531
- Utworzono: {posterCreated}, pominięto: {posterSkipped}{posterErrors > 0 ? `, błędów: ${posterErrors}` : ''}
532
- </p>
533
- <p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
534
- {posterCurrentFile}
535
- </p>
536
- <Button
537
- variant="outline"
538
- size="sm"
539
- onclick={cancelPosterGenerate}
540
- class="mt-2"
541
- >
542
- <PlayerStop class="size-4" />
543
- Anuluj
544
- </Button>
498
+ {#if report.videosCount === 0}
499
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--muted-foreground) 15%, transparent); color: var(--muted-foreground);">Brak wideo</span>
500
+ {:else if report.videosMissingPosters === 0}
501
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--success, #3A8A5C) 15%, transparent); color: var(--success, #3A8A5C);">OK</span>
502
+ {:else}
503
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--warning, #C4893A) 15%, transparent); color: var(--warning, #C4893A);">Brakuje {report.videosMissingPosters}</span>
504
+ {/if}
545
505
  </div>
546
- {:else if report.videosCount > 0}
547
- <Button
548
- variant="default"
549
- size="sm"
550
- onclick={startPosterGenerate}
551
- >
552
- <PlayerPlay class="size-4" />
553
- Generuj brakujące postery
554
- </Button>
555
- {:else}
556
- <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
557
- <CircleCheck class="size-4" />
558
- Brak plików wideo
506
+ </Card.Header>
507
+ <Card.Content>
508
+ <p class="text-sm" style="color: var(--muted-foreground);">
509
+ Miniaturki i postery z plików wideo (ffmpeg)
510
+ </p>
511
+ <div class="mt-3 flex items-baseline gap-1.5">
512
+ <span class="text-2xl font-bold">{report.videosWithPosters}</span>
513
+ <span class="text-sm" style="color: var(--muted-foreground);">/ {report.videosCount} filmów</span>
559
514
  </div>
560
- {/if}
561
- </Card.Content>
562
- </Card.Root>
563
-
564
- <!-- Video transcoding -->
565
- <Card.Root>
566
- <Card.Header>
567
- <div class="flex items-center gap-2">
568
- <Transform class="size-5" style="color: var(--primary);" />
569
- <Card.Title>Transkodowanie wideo</Card.Title>
570
- </div>
571
- <Card.Description>
572
- Warianty wideo (mp4 h264, webm vp9) zoptymalizowane do wyświetlania
573
- </Card.Description>
574
- </Card.Header>
575
- <Card.Content>
576
- <p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
577
- {report.videoStylesDone}
578
- <span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.videoStylesExpected}</span>
579
- </p>
580
- <p class="mb-4 text-xs" style="color: var(--muted-foreground);">
581
- {report.videosCount} filmów{report.videoStylesPending > 0 ? `, ${report.videoStylesPending} w kolejce` : ''}{report.videoStylesFailed > 0 ? `, ${report.videoStylesFailed} błędów` : ''}
582
- </p>
583
-
584
- {#if transcoding}
585
- <div class="mb-4">
586
- <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
587
- <span>{transProcessed}/{transTotal} wariantów</span>
588
- <span>{transPercent}%</span>
515
+
516
+ {#if posterGenerating}
517
+ <div class="mt-3">
518
+ <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
519
+ <span>{posterProcessed}/{posterTotal} filmów</span>
520
+ <span>{posterPercent}%</span>
521
+ </div>
522
+ <div class="h-1.5 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
523
+ <div class="h-full rounded-full transition-all duration-300" style="width: {posterPercent}%; background: var(--primary);"></div>
524
+ </div>
525
+ <p class="mt-1 truncate text-xs" style="color: var(--muted-foreground);">{posterCurrentFile}</p>
526
+ <Button variant="outline" size="sm" onclick={() => posterAbort?.abort()} class="mt-2">
527
+ <PlayerStop class="size-3.5" /> Anuluj
528
+ </Button>
589
529
  </div>
590
- <div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
591
- <div
592
- class="h-full rounded-full transition-all duration-300"
593
- style="width: {transPercent}%; background: var(--primary);"
594
- ></div>
530
+ {:else if report.videosMissingPosters > 0}
531
+ <div class="mt-3">
532
+ <Button variant="default" size="sm" onclick={startPosterGenerate}>
533
+ <PlayerPlay class="size-3.5" /> Generuj brakujące
534
+ </Button>
595
535
  </div>
596
- <p class="mt-1 text-xs" style="color: var(--muted-foreground);">
597
- Utworzono: {transCreated}, pominięto: {transSkipped}{transErrors > 0 ? `, błędów: ${transErrors}` : ''}
598
- </p>
599
- <p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
600
- {transCurrentFile}
601
- </p>
602
- <Button
603
- variant="outline"
604
- size="sm"
605
- onclick={cancelTranscode}
606
- class="mt-2"
607
- >
608
- <PlayerStop class="size-4" />
609
- Anuluj
610
- </Button>
611
- </div>
612
- {:else if videoStylesMissing > 0 || report.videoStylesFailed > 0}
613
- <div class="mb-3">
614
- {#if videoStylesMissing > 0}
615
- <p class="mb-2 text-sm" style="color: var(--warning, #C4893A);">
616
- Brakuje {videoStylesMissing} wariantów wideo.
617
- </p>
618
- {/if}
619
- {#if report.videoStylesFailed > 0}
620
- <p class="mb-2 text-sm" style="color: var(--error, #C44B4B);">
621
- {report.videoStylesFailed} wariantów nie udało się wygenerować.
622
- </p>
536
+ {/if}
537
+ </Card.Content>
538
+ </Card.Root>
539
+
540
+ <!-- Video transcoding -->
541
+ <Card.Root>
542
+ <Card.Header class="pb-3">
543
+ <div class="flex items-center justify-between">
544
+ <div class="flex items-center gap-2">
545
+ <Transform class="size-5" style="color: var(--primary);" />
546
+ <Card.Title class="text-base">Transkodowanie</Card.Title>
547
+ </div>
548
+ {#if report.videosCount === 0}
549
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--muted-foreground) 15%, transparent); color: var(--muted-foreground);">Brak wideo</span>
550
+ {:else if report.videoStylesFailed > 0}
551
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--error, #C44B4B) 15%, transparent); color: var(--error, #C44B4B);">{report.videoStylesFailed} błędów</span>
552
+ {:else if videoStylesMissing > 0}
553
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--warning, #C4893A) 15%, transparent); color: var(--warning, #C4893A);">Brakuje {videoStylesMissing}</span>
554
+ {:else if report.videoStylesPending > 0}
555
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary);">
556
+ <Loader2 class="mr-1 inline size-3 animate-spin" style="vertical-align: -1px;" />
557
+ {report.videoStylesPending} w kolejce
558
+ </span>
559
+ {:else}
560
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--success, #3A8A5C) 15%, transparent); color: var(--success, #3A8A5C);">OK</span>
623
561
  {/if}
624
- <Button
625
- variant="default"
626
- size="sm"
627
- onclick={startTranscode}
628
- >
629
- <PlayerPlay class="size-4" />
630
- Transkoduj brakujące
631
- </Button>
632
562
  </div>
633
- {:else if report.videosCount > 0}
634
- <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
635
- <CircleCheck class="size-4" />
636
- Wszystkie warianty wygenerowane
637
- </div>
638
- {:else}
639
- <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
640
- <CircleCheck class="size-4" />
641
- Brak plików wideo
563
+ </Card.Header>
564
+ <Card.Content>
565
+ <p class="text-sm" style="color: var(--muted-foreground);">
566
+ Warianty wideo (mp4 h264, webm vp9)
567
+ </p>
568
+ <div class="mt-3 flex items-baseline gap-1.5">
569
+ <span class="text-2xl font-bold">{report.videoStylesDone}</span>
570
+ <span class="text-sm" style="color: var(--muted-foreground);">/ {report.videoStylesExpected} wariantów</span>
642
571
  </div>
643
- {/if}
572
+ <p class="mt-0.5 text-xs" style="color: var(--muted-foreground);">
573
+ {report.videosCount} filmów{report.videoStylesPending > 0 ? `, ${report.videoStylesPending} w kolejce` : ''}{report.videoStylesFailed > 0 ? `, ${report.videoStylesFailed} z błędami` : ''}
574
+ </p>
644
575
 
645
- {#if report.videoStylesCount > 0}
646
- <Button
647
- variant="destructive"
648
- size="sm"
649
- onclick={purgeVideoStylesAction}
650
- disabled={purgingVideoStyles || transcoding}
651
- >
652
- {#if purgingVideoStyles}
653
- <Loader2 class="size-4 animate-spin" />
576
+ {#if transcoding}
577
+ <div class="mt-3">
578
+ <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
579
+ <span>{transProcessed}/{transTotal} wariantów</span>
580
+ <span>{transPercent}%</span>
581
+ </div>
582
+ <div class="h-1.5 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
583
+ <div class="h-full rounded-full transition-all duration-300" style="width: {transPercent}%; background: var(--primary);"></div>
584
+ </div>
585
+ <p class="mt-1 truncate text-xs" style="color: var(--muted-foreground);">{transCurrentFile}</p>
586
+ <Button variant="outline" size="sm" onclick={() => transAbort?.abort()} class="mt-2">
587
+ <PlayerStop class="size-3.5" /> Anuluj
588
+ </Button>
589
+ </div>
590
+ {:else}
591
+ <div class="mt-3 flex flex-wrap gap-2">
592
+ {#if videoStylesMissing > 0 || report.videoStylesFailed > 0}
593
+ <Button variant="default" size="sm" onclick={startTranscode}>
594
+ <PlayerPlay class="size-3.5" /> Transkoduj brakujące
595
+ </Button>
596
+ {/if}
597
+ {#if report.videoStylesCount > 0}
598
+ <Button variant="outline" size="sm" onclick={purgeVideoStylesAction} disabled={purgingVideoStyles || transcoding}>
599
+ {#if purgingVideoStyles}<Loader2 class="size-3.5 animate-spin" />{:else}<Trash class="size-3.5" />{/if}
600
+ Regeneruj wszystkie
601
+ </Button>
602
+ {/if}
603
+ </div>
604
+ {/if}
605
+ </Card.Content>
606
+ </Card.Root>
607
+ </div>
608
+ </div>
609
+
610
+ <!-- Section: File Integrity -->
611
+ <div class="mb-8">
612
+ <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
613
+ Integralność plików
614
+ </h2>
615
+ <div class="grid gap-4 md:grid-cols-2">
616
+ <!-- Orphaned disk files -->
617
+ <Card.Root>
618
+ <Card.Header class="pb-3">
619
+ <div class="flex items-center justify-between">
620
+ <div class="flex items-center gap-2">
621
+ <FileSearch class="size-5" style="color: var(--muted-foreground);" />
622
+ <Card.Title class="text-base">Osierocone pliki</Card.Title>
623
+ </div>
624
+ {#if report.orphanedDiskFiles.length === 0}
625
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--success, #3A8A5C) 15%, transparent); color: var(--success, #3A8A5C);">OK</span>
654
626
  {:else}
655
- <Trash class="size-4" />
627
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--warning, #C4893A) 15%, transparent); color: var(--warning, #C4893A);">{report.orphanedDiskFiles.length} plików</span>
656
628
  {/if}
657
- Usuń wszystkie i transkoduj ponownie
658
- </Button>
659
- <p class="mt-2 text-xs" style="color: var(--text-light);">
660
- Warianty zostaną odtworzone przy następnym transkodowaniu
629
+ </div>
630
+ </Card.Header>
631
+ <Card.Content>
632
+ <p class="text-sm" style="color: var(--muted-foreground);">
633
+ Pliki na dysku bez rekordów w bazie danych
661
634
  </p>
662
- {/if}
663
- </Card.Content>
664
- </Card.Root>
665
-
666
- <!-- Orphaned disk files -->
667
- <Card.Root>
668
- <Card.Header>
669
- <div class="flex items-center gap-2">
670
- <FileSearch class="size-5" style="color: var(--warning, #C4893A);" />
671
- <Card.Title>Osierocone pliki</Card.Title>
672
- </div>
673
- <Card.Description>
674
- Pliki na dysku bez odpowiadających rekordów w bazie danych
675
- </Card.Description>
676
- </Card.Header>
677
- <Card.Content>
678
- <p class="mb-4 text-3xl font-bold" style="color: {report.orphanedDiskFiles.length > 0 ? 'var(--warning, #C4893A)' : 'var(--success, #3A8A5C)'};">
679
- {report.orphanedDiskFiles.length}
680
- </p>
681
- {#if report.orphanedDiskFiles.length > 0}
682
- <Button
683
- variant="outline"
684
- size="sm"
685
- onclick={cleanOrphans}
686
- disabled={cleaningOrphans}
687
- >
688
- {#if cleaningOrphans}
689
- <Loader2 class="size-4 animate-spin" />
690
- {:else}
691
- <Trash class="size-4" />
692
- {/if}
693
- Usuń osierocone pliki
694
- </Button>
695
- <details class="mt-3">
696
- <summary class="cursor-pointer text-xs" style="color: var(--text-light);">
697
- Pokaż pliki ({report.orphanedDiskFiles.length})
698
- </summary>
699
- <ul class="mt-1 max-h-40 overflow-auto text-xs" style="color: var(--muted-foreground);">
700
- {#each report.orphanedDiskFiles as file}
701
- <li class="truncate py-0.5">{file}</li>
702
- {/each}
703
- </ul>
704
- </details>
705
- {:else}
706
- <p class="text-sm" style="color: var(--success, #3A8A5C);">Wszystko w porządku</p>
707
- {/if}
708
- </Card.Content>
709
- </Card.Root>
710
-
711
- <!-- Missing disk files -->
712
- <Card.Root>
713
- <Card.Header>
714
- <div class="flex items-center gap-2">
715
- <AlertTriangle class="size-5" style="color: var(--error, #C44B4B);" />
716
- <Card.Title>Brakujące pliki</Card.Title>
717
- </div>
718
- <Card.Description>
719
- Rekordy w bazie danych bez plików na dysku
720
- </Card.Description>
721
- </Card.Header>
722
- <Card.Content>
723
- <p class="mb-4 text-3xl font-bold" style="color: {report.missingDiskRecords.length > 0 ? 'var(--error, #C44B4B)' : 'var(--success, #3A8A5C)'};">
724
- {report.missingDiskRecords.length}
725
- </p>
726
- {#if report.missingDiskRecords.length > 0}
727
- <details>
728
- <summary class="cursor-pointer text-xs" style="color: var(--text-light);">
729
- Pokaż rekordy ({report.missingDiskRecords.length})
730
- </summary>
731
- <ul class="mt-1 max-h-40 overflow-auto text-xs" style="color: var(--muted-foreground);">
732
- {#each report.missingDiskRecords as rec}
733
- <li class="truncate py-0.5">{rec.table}: {rec.url}</li>
734
- {/each}
735
- </ul>
736
- </details>
737
- {:else}
738
- <p class="text-sm" style="color: var(--success, #3A8A5C);">Wszystko w porządku</p>
739
- {/if}
740
- </Card.Content>
741
- </Card.Root>
635
+ {#if report.orphanedDiskFiles.length > 0}
636
+ <p class="mt-2 text-xs" style="color: var(--muted-foreground);">
637
+ Zostaną automatycznie usunięte przy następnym cyklu konserwacji.
638
+ </p>
639
+ <div class="mt-3 flex items-center gap-2">
640
+ <Button variant="outline" size="sm" onclick={cleanOrphans} disabled={cleaningOrphans}>
641
+ {#if cleaningOrphans}<Loader2 class="size-3.5 animate-spin" />{:else}<Trash class="size-3.5" />{/if}
642
+ Usuń teraz
643
+ </Button>
644
+ </div>
645
+ <details class="mt-3">
646
+ <summary class="cursor-pointer text-xs" style="color: var(--muted-foreground);">
647
+ Pokaż pliki ({report.orphanedDiskFiles.length})
648
+ </summary>
649
+ <ul class="mt-1 max-h-40 overflow-auto text-xs" style="color: var(--muted-foreground);">
650
+ {#each report.orphanedDiskFiles as file}
651
+ <li class="truncate py-0.5">{file}</li>
652
+ {/each}
653
+ </ul>
654
+ </details>
655
+ {:else}
656
+ <p class="mt-2 text-sm" style="color: var(--success, #3A8A5C);">Wszystko w porządku</p>
657
+ {/if}
658
+ </Card.Content>
659
+ </Card.Root>
742
660
 
743
- <!-- Disk usage -->
744
- {#if sysInfo}
745
- {@const du = sysInfo.diskUsage}
661
+ <!-- Missing disk files -->
746
662
  <Card.Root>
747
- <Card.Header>
748
- <div class="flex items-center gap-2">
749
- <Database class="size-5" style="color: var(--primary);" />
750
- <Card.Title>Zużycie dysku</Card.Title>
663
+ <Card.Header class="pb-3">
664
+ <div class="flex items-center justify-between">
665
+ <div class="flex items-center gap-2">
666
+ <AlertTriangle class="size-5" style="color: var(--muted-foreground);" />
667
+ <Card.Title class="text-base">Brakujące pliki</Card.Title>
668
+ </div>
669
+ {#if report.missingDiskRecords.length === 0}
670
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--success, #3A8A5C) 15%, transparent); color: var(--success, #3A8A5C);">OK</span>
671
+ {:else}
672
+ <span class="rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--error, #C44B4B) 15%, transparent); color: var(--error, #C44B4B);">{report.missingDiskRecords.length} brakuje</span>
673
+ {/if}
751
674
  </div>
752
- <Card.Description>
753
- Rozmiar plików mediów i wygenerowanych wariantów
754
- </Card.Description>
755
675
  </Card.Header>
756
676
  <Card.Content>
757
- <p class="mb-4 text-3xl font-bold" style="color: var(--primary);">
758
- {formatBytes(du.total)}
677
+ <p class="text-sm" style="color: var(--muted-foreground);">
678
+ Rekordy w bazie danych bez plików na dysku
759
679
  </p>
760
- <dl class="space-y-1.5 text-sm">
761
- {#if du.originals.images > 0}
762
- <div class="flex justify-between">
763
- <dt style="color: var(--muted-foreground);">Obrazy</dt>
764
- <dd class="font-medium">{formatBytes(du.originals.images)}</dd>
680
+ {#if report.missingDiskRecords.length > 0}
681
+ <details class="mt-3">
682
+ <summary class="cursor-pointer text-xs" style="color: var(--muted-foreground);">
683
+ Pokaż rekordy ({report.missingDiskRecords.length})
684
+ </summary>
685
+ <ul class="mt-1 max-h-40 overflow-auto text-xs" style="color: var(--muted-foreground);">
686
+ {#each report.missingDiskRecords as rec}
687
+ <li class="truncate py-0.5">{rec.table}: {rec.url}</li>
688
+ {/each}
689
+ </ul>
690
+ </details>
691
+ {:else}
692
+ <p class="mt-2 text-sm" style="color: var(--success, #3A8A5C);">Wszystko w porządku</p>
693
+ {/if}
694
+ </Card.Content>
695
+ </Card.Root>
696
+ </div>
697
+ </div>
698
+
699
+ <!-- Section: System -->
700
+ {#if sysInfo}
701
+ {@const du = sysInfo.diskUsage}
702
+ {@const si = sysInfo.systemInfo}
703
+ <div>
704
+ <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
705
+ System
706
+ </h2>
707
+ <div class="grid gap-4 md:grid-cols-2">
708
+ <!-- Disk usage -->
709
+ <Card.Root>
710
+ <Card.Header class="pb-3">
711
+ <div class="flex items-center gap-2">
712
+ <Database class="size-5" style="color: var(--primary);" />
713
+ <Card.Title class="text-base">Zużycie dysku</Card.Title>
714
+ </div>
715
+ </Card.Header>
716
+ <Card.Content>
717
+ <p class="mb-3 text-2xl font-bold">{formatBytes(du.total)}</p>
718
+
719
+ <!-- Stacked bar -->
720
+ {#if du.total > 0}
721
+ <div class="mb-3 flex h-2 overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
722
+ {#each diskSegments as seg}
723
+ <div
724
+ class="h-full"
725
+ style="width: {(seg.bytes / du.total * 100).toFixed(1)}%; background: {seg.color};"
726
+ ></div>
727
+ {/each}
765
728
  </div>
766
729
  {/if}
767
- {#if du.originals.videos > 0}
730
+
731
+ <dl class="space-y-1 text-sm">
732
+ {#each diskSegments as seg}
733
+ <div class="flex items-center justify-between">
734
+ <dt class="flex items-center gap-2" style="color: var(--muted-foreground);">
735
+ <span class="inline-block size-2 rounded-full" style="background: {seg.color};"></span>
736
+ {seg.label}
737
+ </dt>
738
+ <dd class="font-medium">{formatBytes(seg.bytes)}</dd>
739
+ </div>
740
+ {/each}
741
+ </dl>
742
+ </Card.Content>
743
+ </Card.Root>
744
+
745
+ <!-- System info -->
746
+ <Card.Root>
747
+ <Card.Header class="pb-3">
748
+ <div class="flex items-center gap-2">
749
+ <Server class="size-5" style="color: var(--primary);" />
750
+ <Card.Title class="text-base">Środowisko</Card.Title>
751
+ </div>
752
+ </Card.Header>
753
+ <Card.Content>
754
+ <dl class="space-y-1.5 text-sm">
768
755
  <div class="flex justify-between">
769
- <dt style="color: var(--muted-foreground);">Wideo</dt>
770
- <dd class="font-medium">{formatBytes(du.originals.videos)}</dd>
756
+ <dt style="color: var(--muted-foreground);">includio-cms</dt>
757
+ <dd class="font-medium">{si.cmsVersion}</dd>
771
758
  </div>
772
- {/if}
773
- {#if du.originals.audio > 0}
774
759
  <div class="flex justify-between">
775
- <dt style="color: var(--muted-foreground);">Audio</dt>
776
- <dd class="font-medium">{formatBytes(du.originals.audio)}</dd>
760
+ <dt style="color: var(--muted-foreground);">Node.js</dt>
761
+ <dd class="font-medium">{si.nodeVersion}</dd>
777
762
  </div>
778
- {/if}
779
- {#if du.originals.pdf > 0}
780
763
  <div class="flex justify-between">
781
- <dt style="color: var(--muted-foreground);">PDF</dt>
782
- <dd class="font-medium">{formatBytes(du.originals.pdf)}</dd>
764
+ <dt style="color: var(--muted-foreground);">PostgreSQL</dt>
765
+ <dd class="font-medium">{si.postgresVersion ?? 'niedostępny'}</dd>
783
766
  </div>
784
- {/if}
785
- {#if du.originals.other > 0}
786
767
  <div class="flex justify-between">
787
- <dt style="color: var(--muted-foreground);">Inne</dt>
788
- <dd class="font-medium">{formatBytes(du.originals.other)}</dd>
768
+ <dt style="color: var(--muted-foreground);">ffmpeg</dt>
769
+ <dd class="font-medium">
770
+ {#if si.ffmpeg.available}
771
+ {si.ffmpeg.version}
772
+ <span class="ml-1.5 inline-flex gap-1">
773
+ <span class="rounded px-1 py-0.5 text-xs" style="background: {si.ffmpeg.codecs.h264 ? 'var(--success, #3A8A5C)' : 'var(--error, #C44B4B)'}; color: white;">h264</span>
774
+ <span class="rounded px-1 py-0.5 text-xs" style="background: {si.ffmpeg.codecs.vp9 ? 'var(--success, #3A8A5C)' : 'var(--error, #C44B4B)'}; color: white;">vp9</span>
775
+ </span>
776
+ {:else}
777
+ <span style="color: var(--error, #C44B4B);">niedostępny</span>
778
+ {/if}
779
+ </dd>
789
780
  </div>
790
- {/if}
791
- {#if du.imageStyles.count > 0}
792
781
  <div class="flex justify-between">
793
- <dt style="color: var(--muted-foreground);">Style obrazów ({du.imageStyles.count})</dt>
794
- <dd class="font-medium">{formatBytes(du.imageStyles.bytes)}</dd>
782
+ <dt style="color: var(--muted-foreground);">sharp</dt>
783
+ <dd class="font-medium">
784
+ {#if si.sharp.available}{si.sharp.version}{:else}<span style="color: var(--error, #C44B4B);">niedostępny</span>{/if}
785
+ </dd>
795
786
  </div>
796
- {/if}
797
- {#if du.videoStyles.count > 0}
798
787
  <div class="flex justify-between">
799
- <dt style="color: var(--muted-foreground);">Style wideo ({du.videoStyles.count})</dt>
800
- <dd class="font-medium">{formatBytes(du.videoStyles.bytes)}</dd>
788
+ <dt style="color: var(--muted-foreground);">OS</dt>
789
+ <dd class="font-medium">{si.os.platform} {si.os.release}</dd>
801
790
  </div>
802
- {/if}
803
- {#if du.posters.count > 0}
804
791
  <div class="flex justify-between">
805
- <dt style="color: var(--muted-foreground);">Postery/miniaturki ({du.posters.count})</dt>
806
- <dd class="font-medium">{formatBytes(du.posters.bytes)}</dd>
792
+ <dt style="color: var(--muted-foreground);">CPU / RAM</dt>
793
+ <dd class="font-medium">{si.os.cpus} rdzeni / {formatBytes(si.os.totalMemory)}</dd>
807
794
  </div>
808
- {/if}
809
- </dl>
810
- </Card.Content>
811
- </Card.Root>
812
-
813
- <!-- System info -->
814
- {@const si = sysInfo.systemInfo}
815
- <Card.Root>
816
- <Card.Header>
817
- <div class="flex items-center gap-2">
818
- <Server class="size-5" style="color: var(--primary);" />
819
- <Card.Title>System</Card.Title>
820
- </div>
821
- <Card.Description>
822
- Informacje o środowisku i zależnościach
823
- </Card.Description>
824
- </Card.Header>
825
- <Card.Content>
826
- <dl class="space-y-1.5 text-sm">
827
- <div class="flex justify-between">
828
- <dt style="color: var(--muted-foreground);">includio-cms</dt>
829
- <dd class="font-medium">{si.cmsVersion}</dd>
830
- </div>
831
- <div class="flex justify-between">
832
- <dt style="color: var(--muted-foreground);">Node.js</dt>
833
- <dd class="font-medium">{si.nodeVersion}</dd>
834
- </div>
835
- <div class="flex justify-between">
836
- <dt style="color: var(--muted-foreground);">PostgreSQL</dt>
837
- <dd class="font-medium">{si.postgresVersion ?? 'niedostępny'}</dd>
838
- </div>
839
- <div class="flex justify-between">
840
- <dt style="color: var(--muted-foreground);">ffmpeg</dt>
841
- <dd class="font-medium">
842
- {#if si.ffmpeg.available}
843
- {si.ffmpeg.version}
844
- {:else}
845
- <span style="color: var(--error, #C44B4B);">niedostępny</span>
846
- {/if}
847
- </dd>
848
- </div>
849
- {#if si.ffmpeg.available}
850
795
  <div class="flex justify-between">
851
- <dt style="color: var(--muted-foreground);">Kodeki</dt>
852
- <dd class="flex gap-1.5 font-medium">
853
- <span
854
- class="rounded px-1.5 py-0.5 text-xs"
855
- style="background: {si.ffmpeg.codecs.h264 ? 'var(--success, #3A8A5C)' : 'var(--error, #C44B4B)'}; color: white;"
856
- >h264</span>
857
- <span
858
- class="rounded px-1.5 py-0.5 text-xs"
859
- style="background: {si.ffmpeg.codecs.vp9 ? 'var(--success, #3A8A5C)' : 'var(--error, #C44B4B)'}; color: white;"
860
- >vp9</span>
861
- </dd>
796
+ <dt style="color: var(--muted-foreground);">Uploads</dt>
797
+ <dd class="max-w-[200px] truncate font-medium" title={si.uploads.path}>{si.uploads.path}</dd>
862
798
  </div>
863
- {/if}
864
- <div class="flex justify-between">
865
- <dt style="color: var(--muted-foreground);">sharp</dt>
866
- <dd class="font-medium">
867
- {#if si.sharp.available}
868
- {si.sharp.version}
869
- {:else}
870
- <span style="color: var(--error, #C44B4B);">niedostępny</span>
871
- {/if}
872
- </dd>
873
- </div>
874
- <div class="flex justify-between">
875
- <dt style="color: var(--muted-foreground);">OS</dt>
876
- <dd class="font-medium">{si.os.platform} {si.os.release}</dd>
877
- </div>
878
- <div class="flex justify-between">
879
- <dt style="color: var(--muted-foreground);">CPU / RAM</dt>
880
- <dd class="font-medium">{si.os.cpus} rdzeni / {formatBytes(si.os.totalMemory)}</dd>
881
- </div>
882
- <div class="flex justify-between">
883
- <dt style="color: var(--muted-foreground);">Katalog uploads</dt>
884
- <dd class="max-w-[200px] truncate font-medium" title={si.uploads.path}>{si.uploads.path}</dd>
885
- </div>
886
- </dl>
887
- </Card.Content>
888
- </Card.Root>
889
- {/if}
890
- </div>
891
-
892
- <!-- Refresh button -->
893
- <div class="mt-6">
894
- <Button variant="outline" size="sm" onclick={loadReport} disabled={loading}>
895
- <Refresh class="size-4" />
896
- Odśwież raport
897
- </Button>
898
- </div>
799
+ </dl>
800
+ </Card.Content>
801
+ </Card.Root>
802
+ </div>
803
+ </div>
804
+ {/if}
899
805
  {/if}
900
806
  </div>