chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,11 +30,17 @@
|
||||
<a
|
||||
href="/feed"
|
||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||
{isActive('/feed') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
{isActive('/feed')
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
aria-label="Galerie"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Galerie</span>
|
||||
</a>
|
||||
@@ -47,9 +53,23 @@
|
||||
aria-label="Hochladen"
|
||||
>
|
||||
<!-- Camera + plus icon -->
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Badge -->
|
||||
{#if $uploadBadgeCount > 0}
|
||||
@@ -68,15 +88,28 @@
|
||||
href="/export"
|
||||
data-testid="bottom-nav-export"
|
||||
class="relative flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||
{isActive('/export') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
{isActive('/export')
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
aria-label="Export"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
<span>Export</span>
|
||||
{#if zipReady}
|
||||
<span class="absolute right-2 top-0 h-2 w-2 rounded-full bg-blue-500" aria-hidden="true"></span>
|
||||
<span class="absolute right-2 top-0 h-2 w-2 rounded-full bg-blue-500" aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -85,11 +118,17 @@
|
||||
<a
|
||||
href="/account"
|
||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||
{isActive('/account') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
{isActive('/account')
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
aria-label="Konto"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Konto</span>
|
||||
</a>
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
error = 'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
||||
error =
|
||||
'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
||||
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||
error = 'Keine Kamera gefunden.';
|
||||
} else {
|
||||
@@ -162,8 +163,18 @@
|
||||
{#if error}
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="rounded-lg bg-gray-900 p-6 text-center">
|
||||
<svg class="mx-auto mb-3 h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
class="mx-auto mb-3 h-12 w-12 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-white">{error}</p>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
@@ -173,10 +184,7 @@
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
|
||||
>
|
||||
<button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
@@ -193,7 +201,9 @@
|
||||
></video>
|
||||
|
||||
{#if recording}
|
||||
<div class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1">
|
||||
<div
|
||||
class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1"
|
||||
>
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
|
||||
</div>
|
||||
@@ -218,7 +228,9 @@
|
||||
aria-selected={mode === 'photo'}
|
||||
onclick={() => setMode('photo')}
|
||||
data-testid="camera-mode-photo"
|
||||
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'photo' ? 'bg-white text-gray-900' : 'text-white/70 hover:text-white active:text-white'}"
|
||||
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'photo'
|
||||
? 'bg-white text-gray-900'
|
||||
: 'text-white/70 hover:text-white active:text-white'}"
|
||||
>
|
||||
Foto
|
||||
</button>
|
||||
@@ -228,7 +240,9 @@
|
||||
aria-selected={mode === 'video'}
|
||||
onclick={() => setMode('video')}
|
||||
data-testid="camera-mode-video"
|
||||
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'video' ? 'bg-white text-gray-900' : 'text-white/70 hover:text-white active:text-white'}"
|
||||
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'video'
|
||||
? 'bg-white text-gray-900'
|
||||
: 'text-white/70 hover:text-white active:text-white'}"
|
||||
>
|
||||
Video
|
||||
</button>
|
||||
@@ -236,7 +250,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-center gap-8 bg-black/80 px-4 pt-4 pb-6" style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)">
|
||||
<div
|
||||
class="flex items-center justify-center gap-8 bg-black/80 px-4 pt-4 pb-6"
|
||||
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)"
|
||||
>
|
||||
<!-- Close -->
|
||||
<button
|
||||
onclick={onclose}
|
||||
@@ -244,7 +261,12 @@
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -252,8 +274,14 @@
|
||||
<button
|
||||
onclick={handleShutter}
|
||||
data-testid="camera-shutter"
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white transition active:scale-95 {recording ? 'bg-red-600' : 'bg-white/20'}"
|
||||
aria-label={mode === 'photo' ? 'Foto aufnehmen' : recording ? 'Aufnahme stoppen' : 'Video aufnehmen'}
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white transition active:scale-95 {recording
|
||||
? 'bg-red-600'
|
||||
: 'bg-white/20'}"
|
||||
aria-label={mode === 'photo'
|
||||
? 'Foto aufnehmen'
|
||||
: recording
|
||||
? 'Aufnahme stoppen'
|
||||
: 'Video aufnehmen'}
|
||||
>
|
||||
{#if mode === 'photo'}
|
||||
<div class="h-12 w-12 rounded-full bg-white"></div>
|
||||
@@ -274,7 +302,12 @@
|
||||
aria-label="Kamera wechseln"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -80,12 +80,16 @@
|
||||
onclick={handleConfirm}
|
||||
disabled={busy}
|
||||
data-testid="confirm-sheet-confirm"
|
||||
class="mb-3 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone === 'danger'
|
||||
class="mb-3 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone ===
|
||||
'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400'}"
|
||||
>
|
||||
{#if busy}
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" aria-hidden="true"></span>
|
||||
<span
|
||||
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -63,7 +63,11 @@
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
} else if (returnFocus) {
|
||||
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
}
|
||||
});
|
||||
@@ -115,7 +119,9 @@
|
||||
</div>
|
||||
|
||||
{#if title}
|
||||
<p class="px-5 pt-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<p
|
||||
class="px-5 pt-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -18,14 +18,7 @@
|
||||
oncontextmenu?: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
upload,
|
||||
isOwn = false,
|
||||
onlike,
|
||||
oncomment,
|
||||
onselect,
|
||||
oncontextmenu
|
||||
}: Props = $props();
|
||||
let { upload, isOwn = false, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
||||
|
||||
function isVideo(mime: string): boolean {
|
||||
return mime.startsWith('video/');
|
||||
@@ -88,7 +81,9 @@
|
||||
{initials(upload.uploader_name)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
|
||||
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{upload.uploader_name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,11 +92,20 @@
|
||||
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); openContext(); }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openContext();
|
||||
}}
|
||||
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active:bg-gray-800 dark:hover:text-gray-200"
|
||||
aria-label="Mehr Aktionen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"
|
||||
><circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle
|
||||
cx="19"
|
||||
cy="12"
|
||||
r="2"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -112,7 +116,9 @@
|
||||
use:doubletap
|
||||
onsingletap={() => onselect(upload)}
|
||||
ondoubletap={handleDoubleTap}
|
||||
onclick={(e) => { if (e.detail === 0) onselect(upload); }}
|
||||
onclick={(e) => {
|
||||
if (e.detail === 0) onselect(upload);
|
||||
}}
|
||||
aria-label="Bild vergrößern"
|
||||
>
|
||||
<HeartBurst active={heartBurst} />
|
||||
@@ -128,7 +134,9 @@
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
||||
<span
|
||||
class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white"
|
||||
>
|
||||
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
@@ -149,9 +157,21 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
|
||||
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" />
|
||||
<div
|
||||
class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -160,11 +180,16 @@
|
||||
<!-- Actions row -->
|
||||
<div class="flex items-center gap-4 px-4 py-2">
|
||||
<button
|
||||
onclick={() => { vibrate(10); onlike(upload.id); }}
|
||||
onclick={() => {
|
||||
vibrate(10);
|
||||
onlike(upload.id);
|
||||
}}
|
||||
aria-pressed={upload.liked_by_me}
|
||||
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
||||
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
||||
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
|
||||
{upload.liked_by_me
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
||||
@@ -173,7 +198,11 @@
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{upload.like_count}
|
||||
</button>
|
||||
@@ -182,7 +211,11 @@
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
{upload.comment_count}
|
||||
</button>
|
||||
|
||||
@@ -18,11 +18,10 @@
|
||||
<button
|
||||
onclick={() => onselect(null)}
|
||||
aria-pressed={selected === null}
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {
|
||||
selected === null
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'
|
||||
}"
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected ===
|
||||
null
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'}"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
@@ -30,11 +29,10 @@
|
||||
<button
|
||||
onclick={() => onselect(h.tag)}
|
||||
aria-pressed={selected === h.tag}
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {
|
||||
selected === h.tag
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'
|
||||
}"
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected ===
|
||||
h.tag
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'}"
|
||||
>
|
||||
#{h.tag}
|
||||
<span class="ml-1 text-xs opacity-70">{h.count}</span>
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
let { active }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes heart-burst {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
30% { transform: scale(1.2); opacity: 1; }
|
||||
70% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if active}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
@@ -26,7 +17,30 @@
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 21s-7-4.534-9.5-9.034C.5 9.466 2.5 5 7 5c2.09 0 3.534 1.083 5 3 1.466-1.917 2.91-3 5-3 4.5 0 6.5 4.466 4.5 6.966C19 16.466 12 21 12 21z" />
|
||||
<path
|
||||
d="M12 21s-7-4.534-9.5-9.034C.5 9.466 2.5 5 7 5c2.09 0 3.534 1.083 5 3 1.466-1.917 2.91-3 5-3 4.5 0 6.5 4.466 4.5 6.966C19 16.466 12 21 12 21z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes heart-burst {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
try {
|
||||
const { comment_id } = JSON.parse(data) as { comment_id: string };
|
||||
comments = comments.filter((c) => c.id !== comment_id);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
|
||||
@@ -66,7 +68,9 @@
|
||||
try {
|
||||
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||||
if (upload_id === upload.id) void loadComments(upload.id);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -124,7 +128,10 @@
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -138,7 +145,9 @@
|
||||
use:scrollLock
|
||||
use:modalInert
|
||||
>
|
||||
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900">
|
||||
<div
|
||||
class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||
>
|
||||
<!-- Media -->
|
||||
<div class="relative bg-black">
|
||||
<button
|
||||
@@ -147,14 +156,15 @@
|
||||
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="relative"
|
||||
use:doubletap
|
||||
ondoubletap={triggerHeartBurst}
|
||||
>
|
||||
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<video
|
||||
src={mediaSrc}
|
||||
@@ -180,19 +190,31 @@
|
||||
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
|
||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
||||
>{upload.uploader_name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
||||
>{formatTime(upload.created_at)}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => onlike(upload.id)}
|
||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
|
||||
upload.liked_by_me
|
||||
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}"
|
||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
<svg
|
||||
class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{upload.like_count}
|
||||
</button>
|
||||
@@ -211,9 +233,13 @@
|
||||
{#each comments as comment (comment.id)}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{comment.uploader_name}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>{comment.uploader_name}</span
|
||||
>
|
||||
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
|
||||
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{formatTime(comment.created_at)}</div>
|
||||
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatTime(comment.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
{#if comment.user_id === userId}
|
||||
<button
|
||||
@@ -222,7 +248,12 @@
|
||||
aria-label="Löschen"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -234,7 +265,10 @@
|
||||
|
||||
<!-- Comment input -->
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}}
|
||||
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
|
||||
@@ -19,14 +19,7 @@
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
titleId,
|
||||
ariaLabel,
|
||||
onClose,
|
||||
closeOnBackdrop = true,
|
||||
children
|
||||
}: Props = $props();
|
||||
let { open, titleId, ariaLabel, onClose, closeOnBackdrop = true, children }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (open && !titleId && !ariaLabel && typeof console !== 'undefined') {
|
||||
@@ -56,7 +49,9 @@
|
||||
use:focusTrap={{ onclose: onClose }}
|
||||
use:scrollLock
|
||||
>
|
||||
<div class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||
<div
|
||||
class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,10 +63,15 @@
|
||||
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
||||
let currentStep = $derived(steps[step]);
|
||||
|
||||
const THEME_OPTIONS: Array<{ value: ThemePreference; label: string; hint: string; icon: string }> = [
|
||||
const THEME_OPTIONS: Array<{
|
||||
value: ThemePreference;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{ value: 'system', label: 'System', hint: 'Folgt der Geräteeinstellung', icon: '🖥️' },
|
||||
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||
];
|
||||
|
||||
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
||||
@@ -106,7 +111,7 @@
|
||||
<!-- Step indicator — tap a pip to jump back. The visible dot is small but
|
||||
the touch target is padded to ~44 px so it remains tappable on mobile. -->
|
||||
<div class="mb-3 flex justify-center">
|
||||
{#each steps as _, i}
|
||||
{#each steps as _, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goToStep(i)}
|
||||
@@ -148,8 +153,8 @@
|
||||
onclick={() => themePreference.set(opt.value)}
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
||||
{selected
|
||||
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
||||
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
<span class="text-2xl leading-none">{opt.icon}</span>
|
||||
<div class="flex-1">
|
||||
@@ -159,11 +164,17 @@
|
||||
<span
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
||||
{selected
|
||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
>
|
||||
{#if selected}
|
||||
<svg class="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<svg
|
||||
class="h-3 w-3 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -186,7 +197,7 @@
|
||||
onclick={next}
|
||||
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
|
||||
>
|
||||
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
|
||||
{step < steps.length - 1 ? 'Weiter' : "Los geht's!"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismissToast(t.id)}
|
||||
class="pointer-events-auto w-full max-w-sm rounded-xl px-4 py-3 text-left text-sm font-medium shadow-lg transition active:scale-[0.98] {toneClasses(t.tone)}"
|
||||
class="pointer-events-auto w-full max-w-sm rounded-xl px-4 py-3 text-left text-sm font-medium shadow-lg transition active:scale-[0.98] {toneClasses(
|
||||
t.tone
|
||||
)}"
|
||||
role={t.tone === 'error' || t.tone === 'warning' ? 'alert' : 'status'}
|
||||
aria-live={t.tone === 'error' || t.tone === 'warning' ? 'assertive' : 'polite'}
|
||||
data-testid="toast"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { queueItems, isProcessing, retryItem, removeItem, clearCompleted, rateLimitRetryAt } from '$lib/upload-queue';
|
||||
import {
|
||||
queueItems,
|
||||
isProcessing,
|
||||
retryItem,
|
||||
removeItem,
|
||||
clearCompleted,
|
||||
rateLimitRetryAt
|
||||
} from '$lib/upload-queue';
|
||||
import type { QueueItem } from '$lib/upload-queue';
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -10,21 +17,31 @@
|
||||
|
||||
function statusLabel(status: QueueItem['status']): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'Wartend';
|
||||
case 'uploading': return 'Wird hochgeladen';
|
||||
case 'done': return 'Fertig';
|
||||
case 'error': return 'Fehler';
|
||||
case 'blocked': return 'Gesperrt';
|
||||
case 'pending':
|
||||
return 'Wartend';
|
||||
case 'uploading':
|
||||
return 'Wird hochgeladen';
|
||||
case 'done':
|
||||
return 'Fertig';
|
||||
case 'error':
|
||||
return 'Fehler';
|
||||
case 'blocked':
|
||||
return 'Gesperrt';
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: QueueItem['status']): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-gray-500 dark:text-gray-400';
|
||||
case 'uploading': return 'text-blue-600 dark:text-blue-400';
|
||||
case 'done': return 'text-green-600 dark:text-green-400';
|
||||
case 'error': return 'text-red-600 dark:text-red-400';
|
||||
case 'blocked': return 'text-red-600 dark:text-red-400';
|
||||
case 'pending':
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
case 'uploading':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'done':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'error':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'blocked':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +69,12 @@
|
||||
</script>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="mt-4 rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Upload-Warteschlange
|
||||
{#if $isProcessing}
|
||||
@@ -71,7 +92,9 @@
|
||||
</div>
|
||||
|
||||
{#if $rateLimitRetryAt && countdown > 0}
|
||||
<div class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<div
|
||||
class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
>
|
||||
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
|
||||
</div>
|
||||
{/if}
|
||||
@@ -81,7 +104,9 @@
|
||||
<li class="px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{item.fileName}</p>
|
||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.fileName}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{formatSize(item.fileSize)}</p>
|
||||
</div>
|
||||
<div class="ml-3 flex items-center gap-2">
|
||||
@@ -103,7 +128,12 @@
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -111,7 +141,9 @@
|
||||
</div>
|
||||
|
||||
{#if item.status === 'uploading'}
|
||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
style="width: {item.progress}%"
|
||||
|
||||
@@ -59,7 +59,11 @@
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
} else if (returnFocus) {
|
||||
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
}
|
||||
});
|
||||
@@ -170,46 +174,74 @@
|
||||
Schließen
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
onclick={openGallery}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Galerie</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Foto oder Video wählen</p>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
onclick={openGallery}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
<span
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Galerie</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Foto oder Video wählen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Camera option -->
|
||||
<button
|
||||
onclick={openCamera}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Kamera</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Camera option -->
|
||||
<button
|
||||
onclick={openCamera}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
<span
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Kamera</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
onclick={close}
|
||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
onclick={close}
|
||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
oncontextmenu?: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props =
|
||||
$props();
|
||||
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
||||
|
||||
const COLS = 3;
|
||||
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
||||
@@ -218,7 +217,11 @@
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="h-10 w-10 text-white/80"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user