Files
EventSnap/frontend/src/routes/upload/+page.svelte
MechaCat02 1cdab21514 fix(frontend): a11y backdrop, ≥44px PIN button, test-ids on auth & upload
- account/+page.svelte: remove `aria-hidden="true"` from the
  leave-confirm and data-mode-warning bottom-sheet backdrops. The
  attribute cascaded into the dialog children, making the inner
  Abmelden/Aktivieren/Abbrechen buttons unreachable in the accessibility
  tree (and to Playwright's `getByRole`). Discovered while writing the
  E2E suite; the visual layout is unchanged.

- join/+page.svelte: bump the PIN-copy button from `py-1` (28px tall) to
  `min-h-11 min-w-11 py-2` so it clears the ≥44px touch-target floor on
  mobile. Touch-target audit revealed the gap.

- data-testid attributes on stable interactive elements (join name input,
  join submit, PIN modal + copy + continue, recovery PIN + submit + try-
  different-name, admin login password + submit + error, recover name +
  PIN + submit + error, upload header submit + sticky submit + caption
  textarea). Targeted at ~20 spots where semantic locators were ambiguous
  (e.g. two "Hochladen" buttons on /upload, German strings that may iterate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:54 +02:00

254 lines
9.7 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue';
import { showBottomNav } from '$lib/ui-store';
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
import { get } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';
import { quotaStore, refreshQuota } from '$lib/quota-store';
import type { PendingFile } from '$lib/pending-upload-store';
interface StagedFile extends PendingFile {
// previewUrl and file inherited from PendingFile
}
let stagedFiles = $state<StagedFile[]>([]);
let caption = $state('');
let submitting = $state(false);
let captionEl: HTMLTextAreaElement;
const MAX_CAPTION_LENGTH = 2000;
// Quick-tag chips derived from caption as the user types
let captionTags = $derived.by(() => {
const matches = [...caption.matchAll(/#(\w+)/g)];
return [...new Set(matches.map((m) => m[1].toLowerCase()))];
});
onMount(() => {
showBottomNav.set(false);
if (!getToken()) {
goto('/join');
return;
}
loadQueue();
void refreshQuota();
// Pull staged files from the pending store (written by UploadSheet)
const pf = get(pendingFiles);
const pc = get(pendingCaption);
stagedFiles = pf;
caption = pc;
// Auto-focus caption textarea after a short delay (let layout settle)
setTimeout(() => captionEl?.focus(), 80);
// Revoke blob URLs if user abandons the upload page
const handleBeforeUnload = () => {
clearPending();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
});
onDestroy(() => {
showBottomNav.set(true);
});
function removeFile(idx: number) {
const removed = stagedFiles[idx];
URL.revokeObjectURL(removed.previewUrl);
stagedFiles = stagedFiles.filter((_, i) => i !== idx);
}
function cancel() {
clearPending();
goto('/feed');
}
async function handleSubmit() {
if (stagedFiles.length === 0 || submitting) return;
if (caption.length > MAX_CAPTION_LENGTH) return;
submitting = true;
const hashtagsString = captionTags.join(',');
for (const sf of stagedFiles) {
await addToQueue(sf.file, caption, hashtagsString);
}
clearPending();
goto('/feed');
}
function isVideo(file: File): boolean {
return file.type.startsWith('video/');
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || bytes <= 0) return '0 MB';
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
const totalStagedBytes = $derived(stagedFiles.reduce((sum, sf) => sum + sf.file.size, 0));
const quotaPercent = $derived(
$quotaStore.limit_bytes && $quotaStore.limit_bytes > 0
? Math.min(100, (($quotaStore.used_bytes + totalStagedBytes) / $quotaStore.limit_bytes) * 100)
: 0
);
</script>
<!-- Full-screen composer — bottom nav is suppressed -->
<div class="flex min-h-screen flex-col bg-white dark:bg-gray-950">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<button
onclick={cancel}
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="Abbrechen"
>
<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="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<h1 class="text-base font-semibold text-gray-900 dark:text-gray-100">Neuer Beitrag</h1>
<!-- Submit button in header for desktop convenience -->
<button
onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting}
data-testid="upload-submit-header"
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition
hover:bg-blue-700 disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
</button>
</div>
<div class="flex flex-1 flex-col overflow-y-auto">
<!-- Thumbnail strip -->
{#if stagedFiles.length > 0}
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
{#each stagedFiles as sf, i}
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800">
{#if isVideo(sf.file)}
<div class="flex h-full w-full items-center justify-center bg-gray-800 dark:bg-gray-700">
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{:else}
<img src={sf.previewUrl} alt="" class="h-full w-full object-cover" />
{/if}
<button
onclick={() => removeFile(i)}
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
aria-label="Entfernen"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
<div class="border-b border-gray-100 dark:border-gray-800"></div>
{:else}
<!-- No files: prompt to go back and pick some -->
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<svg class="h-16 w-16 text-gray-200 dark:text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
</svg>
<div>
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Geh zurück und tippe auf den Plus-Button.</p>
</div>
<button
onclick={cancel}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Zurück
</button>
</div>
{/if}
<!-- Caption textarea -->
<div class="px-4 pt-4">
<textarea
bind:this={captionEl}
bind:value={caption}
maxlength={MAX_CAPTION_LENGTH}
data-testid="upload-caption"
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
rows="4"
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200
dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:bg-gray-800"
></textarea>
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
{caption.length} / {MAX_CAPTION_LENGTH}
</div>
</div>
<!-- Quick-tag chips (derived from typed caption) -->
{#if captionTags.length > 0}
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
{#each captionTags as tag}
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950/40 dark:text-blue-300">
#{tag}
</span>
{/each}
</div>
{/if}
<!-- Per-user quota — hidden when admin disabled enforcement -->
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between">
<span>Speicher: {formatBytes($quotaStore.used_bytes + totalStagedBytes)} / {formatBytes($quotaStore.limit_bytes)}</span>
<span class:text-amber-600={quotaPercent >= 80} class:dark:text-amber-400={quotaPercent >= 80} class:text-red-600={quotaPercent >= 95} class:dark:text-red-400={quotaPercent >= 95}>
{Math.round(quotaPercent)}%
</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full transition-all"
class:bg-blue-500={quotaPercent < 80}
class:bg-amber-500={quotaPercent >= 80 && quotaPercent < 95}
class:bg-red-500={quotaPercent >= 95}
style="width: {quotaPercent}%"
></div>
</div>
</div>
{/if}
<div class="h-8"></div>
</div>
<!-- Sticky submit button at bottom (mobile-primary) -->
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
<button
onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting}
data-testid="upload-submit"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{#if submitting}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Wird hochgeladen…
{:else}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
{stagedFiles.length > 0 ? `${stagedFiles.length} Datei${stagedFiles.length > 1 ? 'en' : ''} hochladen` : 'Hochladen'}
{/if}
</button>
</div>
</div>