fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal + accessible name, a focus-trap/Escape/focus-restore $effect mirroring ContextSheet, and inert + pointer-events-none when closed so its controls leave the tab order and AT tree. H12: a layout-level $effect toasts each upload-queue item the first time it transitions to 'error', so a banned/locked/over-quota guest is actually told why their photo didn't post instead of the composer silently closing. M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against loops on the auth screens), so a 401 no longer strands the user on a dead page. M11: ban and event-lock are pushed to guests. A new uploadsLocked store is seeded from /me/context (now exposes uploads_locked) and kept live by event-closed/opened SSE; the feed shows a banner and the FAB is disabled when locked. A user-banned SSE event force-logs-out the targeted user. M19: feed and export pages track a distinct loadError and render an "Erneut versuchen" retry card instead of collapsing a fetch failure into "empty" / "not released". Light-mode empty-state text bumped to gray-500 for contrast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,9 @@ pub struct MeContextDto {
|
|||||||
pub privacy_note: String,
|
pub privacy_note: String,
|
||||||
pub quota_enabled: bool,
|
pub quota_enabled: bool,
|
||||||
pub storage_quota_enabled: bool,
|
pub storage_quota_enabled: bool,
|
||||||
|
/// Whether uploads are currently locked for the event, so the client can show
|
||||||
|
/// a banner + disable the upload affordance on load (not just via SSE).
|
||||||
|
pub uploads_locked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_context(
|
pub async fn get_context(
|
||||||
@@ -68,6 +71,10 @@ pub async fn get_context(
|
|||||||
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
|
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
|
||||||
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
|
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||||
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||||
|
let uploads_locked = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
|
.await?
|
||||||
|
.map(|e| e.uploads_locked_at.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(Json(MeContextDto {
|
Ok(Json(MeContextDto {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -76,5 +83,6 @@ pub async fn get_context(
|
|||||||
privacy_note,
|
privacy_note,
|
||||||
quota_enabled,
|
quota_enabled,
|
||||||
storage_quota_enabled,
|
storage_quota_enabled,
|
||||||
|
uploads_locked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { getToken, clearAuth } from './auth';
|
import { getToken, clearAuth } from './auth';
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
const BASE = '/api/v1';
|
||||||
@@ -42,6 +44,11 @@ async function request<T>(
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
// Don't strand the user on a now-unauthenticated page with no nav —
|
||||||
|
// send them to /join. Guard against loops on the auth screens.
|
||||||
|
if (browser && !/^\/(join|recover|admin\/login)/.test(window.location.pathname)) {
|
||||||
|
void goto('/join');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
|
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
|
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
|
||||||
import { exportStatus, initExportStatus } from '$lib/export-status-store';
|
import { exportStatus, initExportStatus } from '$lib/export-status-store';
|
||||||
|
import { uploadsLocked } from '$lib/event-state-store';
|
||||||
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
return $page.url.pathname.startsWith(path);
|
return $page.url.pathname.startsWith(path);
|
||||||
@@ -43,8 +44,9 @@
|
|||||||
<div class="relative -translate-y-3">
|
<div class="relative -translate-y-3">
|
||||||
<button
|
<button
|
||||||
onclick={() => ($uploadSheetOpen = true)}
|
onclick={() => ($uploadSheetOpen = true)}
|
||||||
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700"
|
disabled={$uploadsLocked}
|
||||||
aria-label="Hochladen"
|
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-blue-600 disabled:active:scale-100"
|
||||||
|
aria-label={$uploadsLocked ? 'Uploads gesperrt' : 'Hochladen'}
|
||||||
>
|
>
|
||||||
<!-- Camera + plus icon -->
|
<!-- Camera + plus icon -->
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
let showCamera = $state(false);
|
let showCamera = $state(false);
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
let sheet = $state<HTMLDivElement | null>(null);
|
||||||
|
let returnFocus: HTMLElement | null = null;
|
||||||
|
|
||||||
// Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
|
// Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
|
||||||
let open = $derived($uploadSheetOpen);
|
let open = $derived($uploadSheetOpen);
|
||||||
@@ -15,6 +17,50 @@
|
|||||||
uploadSheetOpen.set(false);
|
uploadSheetOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modal contract (mirrors ContextSheet): trap Tab within the sheet, close on
|
||||||
|
// Escape, and restore focus on close. The sheet is permanently mounted (CSS
|
||||||
|
// translate animation), so this is wired via $effect — and `inert`/`aria-hidden`
|
||||||
|
// below keep its controls out of the tab order + AT tree while closed.
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab' || !sheet) return;
|
||||||
|
const list = Array.from(sheet.querySelectorAll<HTMLElement>('button:not([disabled])'));
|
||||||
|
if (list.length === 0) return;
|
||||||
|
const first = list[0];
|
||||||
|
const last = list[list.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (e.shiftKey && (active === first || !sheet.contains(active))) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const first = sheet?.querySelector<HTMLButtonElement>('button:not([disabled])');
|
||||||
|
first?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
} else if (returnFocus) {
|
||||||
|
try {
|
||||||
|
returnFocus.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
/* element gone */
|
||||||
|
}
|
||||||
|
returnFocus = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function openGallery() {
|
function openGallery() {
|
||||||
fileInput?.click();
|
fileInput?.click();
|
||||||
}
|
}
|
||||||
@@ -80,10 +126,17 @@
|
|||||||
|
|
||||||
<!-- Sheet -->
|
<!-- Sheet -->
|
||||||
<div
|
<div
|
||||||
|
bind:this={sheet}
|
||||||
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
|
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
|
||||||
class:translate-y-full={!open}
|
class:translate-y-full={!open}
|
||||||
class:translate-y-0={open}
|
class:translate-y-0={open}
|
||||||
|
class:pointer-events-none={!open}
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Hochladen"
|
||||||
|
tabindex="-1"
|
||||||
|
inert={!open}
|
||||||
>
|
>
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
<div class="flex justify-center pt-3 pb-1">
|
<div class="flex justify-center pt-3 pb-1">
|
||||||
|
|||||||
10
frontend/src/lib/event-state-store.ts
Normal file
10
frontend/src/lib/event-state-store.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Shared, reactive event state pushed from the backend.
|
||||||
|
//
|
||||||
|
// `uploadsLocked` is seeded from `/me/context` on boot and kept live by the
|
||||||
|
// `event-closed` / `event-opened` SSE events (wired in the root layout). Pages
|
||||||
|
// read it to show an "Uploads gesperrt" banner and disable the upload FAB, so a
|
||||||
|
// locked event stops inviting uploads that would only fail server-side.
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const uploadsLocked = writable<boolean>(false);
|
||||||
@@ -55,6 +55,7 @@ export interface MeContextDto {
|
|||||||
privacy_note: string;
|
privacy_note: string;
|
||||||
quota_enabled: boolean;
|
quota_enabled: boolean;
|
||||||
storage_quota_enabled: boolean;
|
storage_quota_enabled: boolean;
|
||||||
|
uploads_locked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
|
import { initAuth, getToken, getUserId, clearPin, clearAuth } from '$lib/auth';
|
||||||
import { initTheme } from '$lib/theme-store';
|
import { initTheme } from '$lib/theme-store';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||||
import UploadSheet from '$lib/components/UploadSheet.svelte';
|
import UploadSheet from '$lib/components/UploadSheet.svelte';
|
||||||
@@ -14,12 +15,28 @@
|
|||||||
import { refreshQuota } from '$lib/quota-store';
|
import { refreshQuota } from '$lib/quota-store';
|
||||||
import { onSseEvent } from '$lib/sse';
|
import { onSseEvent } from '$lib/sse';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { toast } from '$lib/toast-store';
|
||||||
|
import { uploadsLocked } from '$lib/event-state-store';
|
||||||
import type { MeContextDto } from '$lib/types';
|
import type { MeContextDto } from '$lib/types';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let unsubs: Array<() => void> = [];
|
let unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
|
// H12: surface upload failures. The queue marks failed items 'error' in
|
||||||
|
// IndexedDB but nothing rendered them, so a banned/locked/over-quota guest saw
|
||||||
|
// the composer close to a normal feed and assumed success. Toast each item the
|
||||||
|
// first time it transitions to 'error'.
|
||||||
|
let toastedErrors = new Set<string>();
|
||||||
|
$effect(() => {
|
||||||
|
for (const item of $queueItems) {
|
||||||
|
if (item.status === 'error' && !toastedErrors.has(item.id)) {
|
||||||
|
toastedErrors.add(item.id);
|
||||||
|
toast(item.error ?? 'Upload fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Slim progress bar: ratio of completed items to total, shown while processing.
|
// Slim progress bar: ratio of completed items to total, shown while processing.
|
||||||
let progressPct = $derived.by(() => {
|
let progressPct = $derived.by(() => {
|
||||||
const total = $queueItems.length;
|
const total = $queueItems.length;
|
||||||
@@ -39,6 +56,7 @@
|
|||||||
try {
|
try {
|
||||||
const ctx = await api.get<MeContextDto>('/me/context');
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
privacyNote.set(ctx.privacy_note);
|
privacyNote.set(ctx.privacy_note);
|
||||||
|
uploadsLocked.set(ctx.uploads_locked);
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||||
// a session land on /join anyway, and the per-page mount will retry.
|
// a session land on /join anyway, and the per-page mount will retry.
|
||||||
@@ -61,7 +79,26 @@
|
|||||||
} catch {
|
} catch {
|
||||||
// Malformed payload — discard; nothing actionable for the user.
|
// Malformed payload — discard; nothing actionable for the user.
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
// M11: a host banned someone. If it's us, the session has already been
|
||||||
|
// revoked server-side — drop local auth and send us to /join so the UI
|
||||||
|
// doesn't keep pretending we're signed in.
|
||||||
|
onSseEvent('user-banned', (data) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { user_id: string };
|
||||||
|
if (payload.user_id === getUserId()) {
|
||||||
|
clearAuth();
|
||||||
|
toast('Du wurdest vom Event entfernt.', 'error');
|
||||||
|
void goto('/join');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* malformed — ignore */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// M11: reflect event lock state live so the feed/upload pages can toggle
|
||||||
|
// their "Uploads gesperrt" banner and disable the FAB.
|
||||||
|
onSseEvent('event-closed', () => uploadsLocked.set(true)),
|
||||||
|
onSseEvent('event-opened', () => uploadsLocked.set(false))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
let status = $state<ExportStatus | null>(null);
|
let status = $state<ExportStatus | null>(null);
|
||||||
let showHtmlGuide = $state(false);
|
let showHtmlGuide = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let loadError = $state(false);
|
||||||
|
|
||||||
let unsubscribers: (() => void)[] = [];
|
let unsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
@@ -53,9 +54,12 @@
|
|||||||
async function loadStatus() {
|
async function loadStatus() {
|
||||||
try {
|
try {
|
||||||
status = await api.get<ExportStatus>('/export/status');
|
status = await api.get<ExportStatus>('/export/status');
|
||||||
|
loadError = false;
|
||||||
} catch {
|
} catch {
|
||||||
// Background poll triggered by SSE — silent. The visible empty/loading state
|
// M19: a fetch failure must not masquerade as "not yet released" — only
|
||||||
// will reflect the failure; the next event will retry.
|
// flag an error when we have nothing to show; a background SSE poll that
|
||||||
|
// fails while we already have a status stays silent.
|
||||||
|
if (!status) loadError = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -174,6 +178,16 @@
|
|||||||
<div class="mx-auto max-w-lg space-y-4 p-4">
|
<div class="mx-auto max-w-lg space-y-4 p-4">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<p class="font-medium text-gray-700 dark:text-gray-300">Status konnte nicht geladen werden.</p>
|
||||||
|
<button
|
||||||
|
onclick={() => { loading = true; loadStatus(); }}
|
||||||
|
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else if !status?.released}
|
{:else if !status?.released}
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { toast, toastError } from '$lib/toast-store';
|
import { toast, toastError } from '$lib/toast-store';
|
||||||
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
|
import { uploadsLocked } from '$lib/event-state-store';
|
||||||
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
||||||
|
|
||||||
let uploads = $state<FeedUpload[]>([]);
|
let uploads = $state<FeedUpload[]>([]);
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
let nextCursor = $state<string | null>(null);
|
let nextCursor = $state<string | null>(null);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let initialLoading = $state(true);
|
let initialLoading = $state(true);
|
||||||
|
let loadError = $state(false);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
||||||
let selectedUpload = $state<FeedUpload | null>(null);
|
let selectedUpload = $state<FeedUpload | null>(null);
|
||||||
@@ -246,13 +248,18 @@
|
|||||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||||
uploads = res.uploads;
|
uploads = res.uploads;
|
||||||
nextCursor = res.next_cursor;
|
nextCursor = res.next_cursor;
|
||||||
|
loadError = false;
|
||||||
// Seed the SSE reconnect cursor from the newest server timestamp so the
|
// Seed the SSE reconnect cursor from the newest server timestamp so the
|
||||||
// delta on the next reconnect is based on server time, not the client
|
// delta on the next reconnect is based on server time, not the client
|
||||||
// clock (M9).
|
// clock (M9).
|
||||||
if (uploads.length) setLastEventTime(uploads[0].created_at);
|
if (uploads.length) setLastEventTime(uploads[0].created_at);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
|
// M19: distinguish a load failure from a genuinely empty gallery so the
|
||||||
if (!refresh) toastError(e);
|
// template can offer a retry instead of showing "nobody posted yet".
|
||||||
|
if (!refresh) {
|
||||||
|
loadError = true;
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
initialLoading = false;
|
initialLoading = false;
|
||||||
}
|
}
|
||||||
@@ -524,6 +531,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Uploads-locked banner (M11) -->
|
||||||
|
{#if $uploadsLocked}
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-2 max-w-2xl rounded-lg bg-amber-100 px-4 py-2 text-center text-sm font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Uploads sind aktuell gesperrt.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
{#if initialLoading && uploads.length === 0}
|
{#if initialLoading && uploads.length === 0}
|
||||||
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
|
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
|
||||||
@@ -539,10 +556,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if uploads.length === 0 && loadError}
|
||||||
|
<div class="py-20 text-center">
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-300">Galerie konnte nicht geladen werden.</p>
|
||||||
|
<button
|
||||||
|
onclick={() => loadFeed()}
|
||||||
|
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else if uploads.length === 0}
|
{:else if uploads.length === 0}
|
||||||
<div class="py-20 text-center">
|
<div class="py-20 text-center">
|
||||||
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
|
<p class="text-lg text-gray-500 dark:text-gray-400">Noch keine Fotos.</p>
|
||||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Tippe auf den Plus-Button unten!</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if viewMode === 'list'}
|
{:else if viewMode === 'list'}
|
||||||
<!-- List view: chronological full-width cards -->
|
<!-- List view: chronological full-width cards -->
|
||||||
|
|||||||
Reference in New Issue
Block a user