|
| 1 | +<script lang="ts"> |
| 2 | + import { getPreferences } from "$ctx"; |
| 3 | + import TypeBadge from "$lib/components/newsroom/TypeBadge.svelte"; |
| 4 | + import { clientLocale } from "$lib/hooks/client-locale.svelte"; |
| 5 | + import { cn } from "$lib/shared/utils"; |
| 6 | + import type { Author, AuthorView, Post } from "$types"; |
| 7 | + import ImageIcon from "@lucide/svelte/icons/image"; |
| 8 | + import Star from "@lucide/svelte/icons/star"; |
| 9 | + import { Avatar, Button } from "bits-ui"; |
| 10 | +
|
| 11 | + /** `as` sets the title's heading level so the card fits its surrounding document outline. */ |
| 12 | + const { post, as = "h3" }: { post: Post; as?: "h2" | "h3" } = $props(); |
| 13 | +
|
| 14 | + const preferences = getPreferences(); |
| 15 | +
|
| 16 | + const dateFormatter = $derived(new Intl.DateTimeFormat(clientLocale.current, { year: "numeric", month: "long", day: "numeric" })); |
| 17 | + const formatDate = (iso: string | null | undefined): string => { |
| 18 | + if (!iso) return ""; |
| 19 | + try { |
| 20 | + return dateFormatter.format(new Date(iso)); |
| 21 | + } catch { |
| 22 | + return iso; |
| 23 | + } |
| 24 | + }; |
| 25 | +
|
| 26 | + const authorOf = (author: Author | string): AuthorView => (typeof author === "string" ? { id: author, name: "Unknown" } : author); |
| 27 | +
|
| 28 | + const author = $derived(authorOf(post.author)); |
| 29 | + const displayName = $derived(author.displayName?.trim() || author.name); |
| 30 | + const initials = $derived(displayName.slice(0, 2).toUpperCase()); |
| 31 | + const thumb = $derived(post.heroImage?.sizes?.card ?? post.heroImage?.sizes?.thumbnail ?? (post.heroImage ? { url: post.heroImage.url, width: post.heroImage.width, height: post.heroImage.height } : null)); |
| 32 | + const visibleTags = $derived(post.tags?.slice(0, 3) ?? []); |
| 33 | + const overflowTags = $derived((post.tags?.length ?? 0) - visibleTags.length); |
| 34 | +</script> |
| 35 | + |
| 36 | +<Button.Root href="/newsroom/{post.slug}" data-sveltekit-preload-data="hover" class={cn("group relative flex h-full w-full min-w-0 flex-col overflow-hidden rounded-lg text-left", preferences.performanceMode ? "bg-background-grey" : "backdrop-blur-lg backdrop-brightness-150 backdrop-contrast-60 dark:backdrop-brightness-50 dark:backdrop-contrast-100")}> |
| 37 | + <div class="relative aspect-video w-full overflow-hidden bg-background-lore"> |
| 38 | + {#if thumb && post.heroImage} |
| 39 | + <Avatar.Root class="size-full transition-transform duration-300 ease-out group-hover:scale-105"> |
| 40 | + <Avatar.Image src={thumb.url} alt={post.heroImage.alt ?? ""} width={thumb.width} height={thumb.height} loading="lazy" class="size-full object-cover" /> |
| 41 | + <Avatar.Fallback class="flex size-full items-center justify-center bg-text/10"> |
| 42 | + <ImageIcon class="size-6" aria-label="Image failed to load" /> |
| 43 | + </Avatar.Fallback> |
| 44 | + </Avatar.Root> |
| 45 | + {/if} |
| 46 | + <div class="pointer-events-none absolute inset-0 bg-linear-to-t from-background/70 via-background/10 to-transparent group-hover:opacity-0 transition-opacity duration-300 ease-out"></div> |
| 47 | + {#if post.featured} |
| 48 | + <Star class="size-6 absolute top-2 left-2 shrink-0 fill-gold text-gold" aria-label="Featured" /> |
| 49 | + {/if} |
| 50 | + </div> |
| 51 | + <div class="flex flex-1 flex-col gap-2.5 p-4"> |
| 52 | + <div class="flex items-center justify-between gap-4 text-sm"> |
| 53 | + <div class="flex min-w-0 items-center justify-center gap-2"> |
| 54 | + {#if author.mcUuid} |
| 55 | + <Avatar.Root class="size-4 shrink-0"> |
| 56 | + <Avatar.Image loading="lazy" src="https://nmsr.nickac.dev/face/{author.mcUuid}" alt={displayName} class="size-full [image-rendering:pixelated]" /> |
| 57 | + <Avatar.Fallback class="flex size-full items-center justify-center bg-text/10 text-[0.5rem] font-semibold text-text/60 uppercase">{initials}</Avatar.Fallback> |
| 58 | + </Avatar.Root> |
| 59 | + {/if} |
| 60 | + <span class="truncate text-text/60">{displayName}</span> |
| 61 | + </div> |
| 62 | + <time datetime={post.publishedAt} class="shrink-0 text-text/60">{formatDate(post.publishedAt)}</time> |
| 63 | + </div> |
| 64 | + |
| 65 | + <svelte:element this={as} class="text-xl leading-tight font-bold text-text transition-colors group-hover:text-hover">{post.title}</svelte:element> |
| 66 | + |
| 67 | + {#if post.excerpt} |
| 68 | + <p class="line-clamp-3 text-sm leading-relaxed text-text/80">{post.excerpt}</p> |
| 69 | + {/if} |
| 70 | + |
| 71 | + <div class="mt-auto flex flex-wrap items-center gap-1.5 pt-1"> |
| 72 | + <TypeBadge type={post.type} /> |
| 73 | + {#each visibleTags as tag (tag)} |
| 74 | + <span class="rounded-full bg-text/10 px-2 py-0.5 text-[10px] font-medium text-text/70">#{tag}</span> |
| 75 | + {/each} |
| 76 | + {#if overflowTags > 0} |
| 77 | + <span class="rounded-full bg-text/10 px-2 py-0.5 text-[10px] font-medium text-text/50">+{overflowTags}</span> |
| 78 | + {/if} |
| 79 | + </div> |
| 80 | + </div> |
| 81 | +</Button.Root> |
0 commit comments