feat: mobile-first UI redesign (v0.15.0)
- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages - Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer - Upload page redesigned as full-screen composer with thumbnail strip, textarea, quick-tag chips, sticky submit button; bottom nav suppressed while composing - Slim upload progress bar above bottom nav driven by queue state - Feed: list/grid view toggle; list = chronological full-width FeedListCard; grid = 3-col with search bar, autocomplete from loaded posts, filter chips - Account page: role-gated dashboard links (Host / Admin); Konto section with leave-confirm bottom sheet; no more per-page header nav icons - Host dashboard: back arrow, collapsible sections, 2-col stats, user search - Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer), stacked config inputs with sticky save, new Nutzer tab - BottomNav hidden on unauthenticated pages via isAuthenticated store - FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB - Concept docs added to docs/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
140
frontend/src/lib/components/FeedListCard.svelte
Normal file
140
frontend/src/lib/components/FeedListCard.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
upload: FeedUpload;
|
||||
onlike: (id: string) => void;
|
||||
oncomment: (id: string) => void;
|
||||
onselect: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let { upload, onlike, oncomment, onselect }: Props = $props();
|
||||
|
||||
function isVideo(mime: string): boolean {
|
||||
return mime.startsWith('video/');
|
||||
}
|
||||
|
||||
function mediaUrl(u: FeedUpload): string {
|
||||
return u.preview_url ?? u.thumbnail_url ?? '';
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'gerade eben';
|
||||
if (mins < 60) return `vor ${mins} Min.`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `vor ${hrs} Std.`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||
}
|
||||
|
||||
function initial(name: string): string {
|
||||
return name[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
// Deterministic color from name
|
||||
const COLORS = [
|
||||
'bg-blue-100 text-blue-700',
|
||||
'bg-purple-100 text-purple-700',
|
||||
'bg-green-100 text-green-700',
|
||||
'bg-amber-100 text-amber-700',
|
||||
'bg-rose-100 text-rose-700',
|
||||
'bg-teal-100 text-teal-700',
|
||||
];
|
||||
function avatarColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="bg-white">
|
||||
<!-- Uploader row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
|
||||
{avatarColor(upload.uploader_name)}"
|
||||
>
|
||||
{initial(upload.uploader_name)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-gray-900">{upload.uploader_name}</p>
|
||||
<p class="text-xs text-gray-400">{relativeTime(upload.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<button
|
||||
class="block w-full"
|
||||
onclick={() => onselect(upload)}
|
||||
aria-label="Bild vergrößern"
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="relative aspect-video w-full bg-gray-900">
|
||||
{#if mediaUrl(upload)}
|
||||
<img src={mediaUrl(upload)} alt="" class="h-full w-full object-cover opacity-80" />
|
||||
{/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">
|
||||
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mediaUrl(upload)}
|
||||
<img
|
||||
src={mediaUrl(upload)}
|
||||
alt=""
|
||||
class="w-full object-cover"
|
||||
style="max-height: 80svh"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
|
||||
<svg class="h-12 w-12 text-gray-300" 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}
|
||||
</button>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="flex items-center gap-4 px-4 py-2">
|
||||
<button
|
||||
onclick={() => onlike(upload.id)}
|
||||
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
||||
{upload.liked_by_me ? 'text-red-500' : 'text-gray-500 hover:text-red-400'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
</svg>
|
||||
{upload.like_count}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => oncomment(upload.id)}
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
{upload.comment_count}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
{#if upload.caption}
|
||||
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere]">
|
||||
{upload.caption}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="border-b border-gray-100"></div>
|
||||
</article>
|
||||
Reference in New Issue
Block a user