The per-user quota widget was shown to everyone and the /me/quota payload returned free_disk_bytes (raw server free space) and active_uploaders to any authenticated guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero the server-wide telemetry fields for non-staff in the handler. Guests still get their own used/limit so enforcement stays transparent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
12 KiB
Svelte
349 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { getToken, getRole } from '$lib/auth';
|
||
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
||
import { toast } from '$lib/toast-store';
|
||
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 ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||
import IconButton from '$lib/components/IconButton.svelte';
|
||
import { vibrate } from '$lib/haptics';
|
||
import type { PendingFile } from '$lib/pending-upload-store';
|
||
|
||
// StagedFile is just PendingFile under a domain-specific name (previewUrl + file).
|
||
type StagedFile = PendingFile;
|
||
|
||
let stagedFiles = $state<StagedFile[]>([]);
|
||
let caption = $state('');
|
||
let submitting = $state(false);
|
||
let captionEl: HTMLTextAreaElement;
|
||
let discardConfirmOpen = $state(false);
|
||
|
||
const MAX_CAPTION_LENGTH = 2000;
|
||
|
||
// The storage widget is staff-only (host/admin). Guests never see server-derived
|
||
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
|
||
// this is UI-consistency on top of an API guarantee, not the security boundary.
|
||
const isStaff = getRole() === 'host' || getRole() === 'admin';
|
||
|
||
// 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);
|
||
|
||
// Warn before a hard browser navigation (close / reload / external link) drops
|
||
// staged files or an unsent caption. In-app navigation is already guarded by
|
||
// the discard ConfirmSheet in cancel().
|
||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||
if (stagedFiles.length > 0 || caption.trim().length > 0) {
|
||
e.preventDefault();
|
||
e.returnValue = '';
|
||
}
|
||
};
|
||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||
|
||
return () => {
|
||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||
};
|
||
});
|
||
|
||
onDestroy(() => {
|
||
showBottomNav.set(true);
|
||
// Revoke any staged preview blob URLs on leave so they don't leak. The queue
|
||
// holds the underlying File objects, so this is safe after submit too.
|
||
for (const sf of stagedFiles) URL.revokeObjectURL(sf.previewUrl);
|
||
});
|
||
|
||
function removeFile(idx: number) {
|
||
const removed = stagedFiles[idx];
|
||
URL.revokeObjectURL(removed.previewUrl);
|
||
stagedFiles = stagedFiles.filter((_, i) => i !== idx);
|
||
}
|
||
|
||
function cancel() {
|
||
if (stagedFiles.length > 0 || caption.trim().length > 0) {
|
||
discardConfirmOpen = true;
|
||
return;
|
||
}
|
||
clearPending();
|
||
goto('/feed');
|
||
}
|
||
|
||
function confirmDiscard() {
|
||
discardConfirmOpen = false;
|
||
clearPending();
|
||
goto('/feed');
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (stagedFiles.length === 0 || submitting) return;
|
||
if (caption.length > MAX_CAPTION_LENGTH) return;
|
||
submitting = true;
|
||
vibrate(10);
|
||
const hashtagsString = captionTags.join(',');
|
||
let full = 0;
|
||
for (const sf of stagedFiles) {
|
||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||
if (result === 'full') full++;
|
||
}
|
||
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||
if (full > 0) {
|
||
toast(
|
||
`Warteschlange voll – ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
|
||
'error',
|
||
6000
|
||
);
|
||
}
|
||
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 pt-[calc(env(safe-area-inset-top)+0.75rem)] dark:border-gray-800"
|
||
>
|
||
<IconButton label="Abbrechen" onclick={cancel}>
|
||
<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>
|
||
</IconButton>
|
||
<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="btn btn-primary btn-sm"
|
||
>
|
||
{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 (sf.previewUrl)}
|
||
<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-8 w-8 items-center justify-center rounded-full bg-black/60 text-white"
|
||
aria-label="Entfernen"
|
||
>
|
||
<svg
|
||
class="h-4 w-4"
|
||
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">
|
||
<div
|
||
class="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-900/20"
|
||
>
|
||
<svg
|
||
class="h-9 w-9 text-primary-500 dark:text-primary-500"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<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>
|
||
<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="btn btn-secondary btn-sm"> 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="input resize-none text-sm"
|
||
></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 (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 — staff-only (never shown to guests), and also hidden when
|
||
admin disabled enforcement. -->
|
||
{#if isStaff && $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>
|
||
{#if quotaPercent >= 100}
|
||
<p class="mt-1 font-medium text-red-600 dark:text-red-400">
|
||
Limit erreicht — bitte alte Beiträge löschen.
|
||
</p>
|
||
{:else if quotaPercent >= 95}
|
||
<p class="mt-1 font-medium text-amber-600 dark:text-amber-400">Fast voll.</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="h-8"></div>
|
||
</div>
|
||
|
||
<!-- Discard confirmation — appears only when the composer has unsaved content. -->
|
||
<ConfirmSheet
|
||
open={discardConfirmOpen}
|
||
title="Verwerfen?"
|
||
message="Deine Auswahl und der Text gehen verloren."
|
||
confirmLabel="Verwerfen"
|
||
cancelLabel="Weiter bearbeiten"
|
||
tone="danger"
|
||
onConfirm={confirmDiscard}
|
||
onCancel={() => (discardConfirmOpen = false)}
|
||
/>
|
||
|
||
<!-- 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="btn btn-primary btn-block"
|
||
>
|
||
{#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>
|