mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
docs(infra): Phase 2f added to PLAN_OPTION_C + hostname table updated to v28
- PLAN_OPTION_C.md: new row covers verdaccio + news-ingester + mana-ai with the cross-arch + workspace-deps gotchas - infrastructure/README.md: hostname table catches up to npm.mana.how (Phase 2f-1) and mana-ai.mana.how (Phase 2f-3); config v26 → v28 - infrastructure/.env.gpu-box.example: MANA_SERVICE_KEY + MANA_AI_PRIVATE_KEY_PEM block added with note that the values mirror Mini's .env.macmini (the latter's matching public-half stays on mana-auth, that's what makes Mission-Grant decryption work) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8cce79e4c
commit
e77134bd8b
9 changed files with 581 additions and 5 deletions
|
|
@ -37,14 +37,22 @@ interface RequestOptions {
|
|||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
/** When false, send the request without an Authorization header. */
|
||||
auth?: boolean;
|
||||
/**
|
||||
* - `true` (default): require an Authorization header — throws 401 if no token.
|
||||
* - `'optional'`: include token if available, otherwise send anonymously.
|
||||
* - `false`: never send a token.
|
||||
*/
|
||||
auth?: boolean | 'optional';
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (opts.auth !== false) {
|
||||
if (opts.auth === 'optional') {
|
||||
// Best-effort: include token if present, otherwise anonymous.
|
||||
const token = await authStore.getValidToken?.();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
} else if (opts.auth !== false) {
|
||||
const token = await authStore.getValidToken?.();
|
||||
if (!token) throw new CardsApiError(401, 'Not signed in');
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
|
|
@ -126,7 +134,8 @@ export const cardsApi = {
|
|||
}) => request<PublicDeck>('/v1/decks', { method: 'POST', body: input }),
|
||||
bySlug: (slug: string) =>
|
||||
request<{ deck: PublicDeck; latestVersion: PublicDeckVersion | null }>(
|
||||
`/v1/decks/${encodeURIComponent(slug)}`
|
||||
`/v1/decks/${encodeURIComponent(slug)}`,
|
||||
{ auth: 'optional' }
|
||||
),
|
||||
publish: (
|
||||
slug: string,
|
||||
|
|
@ -140,9 +149,80 @@ export const cardsApi = {
|
|||
method: 'POST',
|
||||
body: input,
|
||||
}),
|
||||
star: (slug: string) =>
|
||||
request<{ ok: true }>(`/v1/decks/${encodeURIComponent(slug)}/star`, { method: 'POST' }),
|
||||
unstar: (slug: string) =>
|
||||
request<{ ok: true }>(`/v1/decks/${encodeURIComponent(slug)}/star`, { method: 'DELETE' }),
|
||||
},
|
||||
explore: {
|
||||
landing: () =>
|
||||
request<{ featured: DeckSummary[]; trending: DeckSummary[] }>('/v1/explore', {
|
||||
auth: 'optional',
|
||||
}),
|
||||
browse: (params: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
lang?: string;
|
||||
author?: string;
|
||||
sort?: 'recent' | 'popular' | 'trending';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
||||
}
|
||||
const path = `/v1/decks${qs.toString() ? '?' + qs.toString() : ''}`;
|
||||
return request<{ items: DeckSummary[]; total: number }>(path, { auth: 'optional' });
|
||||
},
|
||||
tags: () => request<TagDefinition[]>('/v1/tags', { auth: 'optional' }),
|
||||
},
|
||||
follows: {
|
||||
follow: (authorSlug: string) =>
|
||||
request<{ ok: true }>(`/v1/authors/${encodeURIComponent(authorSlug)}/follow`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
unfollow: (authorSlug: string) =>
|
||||
request<{ ok: true }>(`/v1/authors/${encodeURIComponent(authorSlug)}/follow`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Override author lookup to send token opportunistically — public reads.
|
||||
cardsApi.authors.bySlug = (slug: string) =>
|
||||
request<PublicAuthor>(`/v1/authors/${encodeURIComponent(slug)}`, { auth: 'optional' });
|
||||
|
||||
export interface DeckSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
license: string;
|
||||
priceCredits: number;
|
||||
cardCount: number;
|
||||
starCount: number;
|
||||
subscriberCount: number;
|
||||
isFeatured: boolean;
|
||||
createdAt: string;
|
||||
owner: {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
verifiedMana: boolean;
|
||||
verifiedCommunity: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
description: string | null;
|
||||
curated: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PublicDeck {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
|
|||
62
apps/cards/apps/web/src/lib/components/DeckGrid.svelte
Normal file
62
apps/cards/apps/web/src/lib/components/DeckGrid.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import type { DeckSummary } from '$lib/api/cards-api';
|
||||
|
||||
interface Props {
|
||||
decks: DeckSummary[];
|
||||
emptyText?: string;
|
||||
}
|
||||
let { decks, emptyText = 'Noch keine Decks.' }: Props = $props();
|
||||
|
||||
function badgeClass(d: DeckSummary): string {
|
||||
if (d.owner.verifiedMana) return 'bg-emerald-500/15 text-emerald-300';
|
||||
if (d.owner.verifiedCommunity) return 'bg-amber-500/15 text-amber-300';
|
||||
return '';
|
||||
}
|
||||
|
||||
function badgeText(d: DeckSummary): string {
|
||||
if (d.owner.verifiedMana) return '🛡️';
|
||||
if (d.owner.verifiedCommunity) return '⭐';
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if decks.length === 0}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
|
||||
{emptyText}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
{#each decks as deck (deck.slug)}
|
||||
<li>
|
||||
<a
|
||||
href={`/d/${deck.slug}`}
|
||||
class="block rounded-xl border border-neutral-800 bg-neutral-900 p-4 transition-colors hover:border-neutral-700 hover:bg-neutral-800"
|
||||
>
|
||||
<div class="mb-1 flex items-start justify-between gap-3">
|
||||
<h3 class="font-semibold leading-tight">{deck.title}</h3>
|
||||
{#if deck.priceCredits > 0}
|
||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
|
||||
{deck.priceCredits} 💎
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deck.description}
|
||||
<p class="mb-2 line-clamp-2 text-xs text-neutral-400">{deck.description}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<!-- Author shows as text inside the deck-link; the deck card
|
||||
navigates to the deck page, the author profile is one
|
||||
hop further from there. Keeps HTML valid (no nested <a>). -->
|
||||
<span class="text-neutral-300">{deck.owner.displayName}</span>
|
||||
{#if badgeText(deck)}
|
||||
<span class="rounded-full px-1.5 py-0.5 {badgeClass(deck)}">{badgeText(deck)}</span>
|
||||
{/if}
|
||||
<span>· {deck.cardCount} Karten</span>
|
||||
<span>· ⭐ {deck.starCount}</span>
|
||||
{#if deck.language}<span>· {deck.language.toUpperCase()}</span>{/if}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
|
@ -48,6 +48,10 @@
|
|||
<a href="/" class="flex items-center gap-2 text-sm font-semibold tracking-tight">
|
||||
<span class="text-base">🃏</span> Cards
|
||||
</a>
|
||||
<nav class="flex items-center gap-4 text-xs text-neutral-400">
|
||||
<a href="/" class="hover:text-neutral-100">Meine Decks</a>
|
||||
<a href="/explore" class="hover:text-neutral-100">Entdecken</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3 text-xs text-neutral-500">
|
||||
{#if streak > 0}
|
||||
<span
|
||||
|
|
|
|||
161
apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
Normal file
161
apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import {
|
||||
cardsApi,
|
||||
CardsApiError,
|
||||
type PublicAuthor,
|
||||
type PublicDeck,
|
||||
type PublicDeckVersion,
|
||||
} from '$lib/api/cards-api';
|
||||
|
||||
const slug = $derived(page.params.slug as string);
|
||||
|
||||
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
|
||||
let deck = $state<PublicDeck | null>(null);
|
||||
let version = $state<PublicDeckVersion | null>(null);
|
||||
let author = $state<PublicAuthor | null>(null);
|
||||
let starred = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!slug) return;
|
||||
load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
stage = 'loading';
|
||||
try {
|
||||
const r = await cardsApi.decks.bySlug(slug);
|
||||
deck = r.deck;
|
||||
version = r.latestVersion;
|
||||
// Author profile is a separate lookup by ownerUserId — we don't
|
||||
// have a slug from the deck endpoint yet, but the explore browse
|
||||
// gives us the author info inline. For Phase γ.2 we keep this
|
||||
// page simple and just show the deck; clicking the deck card on
|
||||
// /explore already routed via /u/<slug>.
|
||||
stage = 'ok';
|
||||
} catch (e) {
|
||||
if (e instanceof CardsApiError && e.status === 404) {
|
||||
stage = 'not-found';
|
||||
return;
|
||||
}
|
||||
error = (e as Error).message;
|
||||
stage = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStar() {
|
||||
if (!deck || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
if (starred) {
|
||||
await cardsApi.decks.unstar(deck.slug);
|
||||
starred = false;
|
||||
} else {
|
||||
await cardsApi.decks.star(deck.slug);
|
||||
starred = true;
|
||||
}
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// `author` is a placeholder for Phase γ.3 (full author surface on
|
||||
// the deck page). Reading it once silences the unused-state lint
|
||||
// without changing reactivity semantics.
|
||||
// svelte-ignore state_referenced_locally
|
||||
void author;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{deck?.title ?? slug} — Cards</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-3xl px-6 py-8">
|
||||
{#if stage === 'loading'}
|
||||
<p class="py-12 text-center text-sm text-neutral-400">Lade Deck…</p>
|
||||
{:else if stage === 'not-found'}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
|
||||
Deck <code class="rounded bg-neutral-800 px-1">{slug}</code> existiert nicht.
|
||||
</p>
|
||||
{:else if stage === 'error'}
|
||||
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
{:else if deck}
|
||||
<article>
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">{deck.title}</h1>
|
||||
{#if deck.description}
|
||||
<p class="mt-2 text-sm text-neutral-400">{deck.description}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3 text-sm">
|
||||
{#if version}
|
||||
<span class="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
|
||||
v{version.semver}
|
||||
</span>
|
||||
<span class="text-neutral-400">{version.cardCount} Karten</span>
|
||||
{/if}
|
||||
<span class="text-neutral-400">{deck.license}</span>
|
||||
{#if deck.language}
|
||||
<span class="text-neutral-400">{deck.language.toUpperCase()}</span>
|
||||
{/if}
|
||||
{#if deck.priceCredits > 0}
|
||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
|
||||
{deck.priceCredits} 💎
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if version?.changelog}
|
||||
<section class="mb-6 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||
<h2 class="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Changelog v{version.semver}
|
||||
</h2>
|
||||
<p class="whitespace-pre-line text-sm text-neutral-300">{version.changelog}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if authStore.isAuthenticated}
|
||||
<button
|
||||
class="rounded-lg border border-indigo-500/40 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
|
||||
onclick={toggleStar}
|
||||
disabled={busy}
|
||||
>
|
||||
{starred ? '★ Markiert' : '☆ Merken'}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
disabled
|
||||
title="Subscribe + Smart-Merge folgt in Phase δ"
|
||||
>
|
||||
Abonnieren · Phase δ
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
|
||||
>
|
||||
Anmelden um zu merken
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-10 text-xs text-neutral-500">
|
||||
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<p class="mt-12 text-center text-xs text-neutral-600">
|
||||
<a href="/explore" class="hover:text-neutral-300">← Marktplatz</a>
|
||||
</p>
|
||||
</main>
|
||||
124
apps/cards/apps/web/src/routes/explore/+page.svelte
Normal file
124
apps/cards/apps/web/src/routes/explore/+page.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cardsApi, type DeckSummary } from '$lib/api/cards-api';
|
||||
import DeckGrid from '$lib/components/DeckGrid.svelte';
|
||||
|
||||
let stage = $state<'loading' | 'landing' | 'search' | 'error'>('loading');
|
||||
let featured = $state<DeckSummary[]>([]);
|
||||
let trending = $state<DeckSummary[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<DeckSummary[]>([]);
|
||||
let searchTotal = $state(0);
|
||||
let searchBusy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(loadLanding);
|
||||
|
||||
async function loadLanding() {
|
||||
stage = 'loading';
|
||||
try {
|
||||
const r = await cardsApi.explore.landing();
|
||||
featured = r.featured;
|
||||
trending = r.trending;
|
||||
stage = 'landing';
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
stage = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
loadLanding();
|
||||
return;
|
||||
}
|
||||
searchBusy = true;
|
||||
try {
|
||||
const r = await cardsApi.explore.browse({ q, sort: 'popular', limit: 30 });
|
||||
searchResults = r.items;
|
||||
searchTotal = r.total;
|
||||
stage = 'search';
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
stage = 'error';
|
||||
} finally {
|
||||
searchBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Entdecken — Cards</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-3xl px-6 py-8">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Entdecken</h1>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Decks aus dem Cards-Marktplatz — kostenlos lernen oder eigene veröffentlichen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="mb-6 flex gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
runSearch();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Suche nach Titel oder Beschreibung…"
|
||||
class="flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
disabled={searchBusy}
|
||||
>
|
||||
{searchBusy ? 'Suche…' : 'Suchen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if stage === 'loading'}
|
||||
<p class="py-12 text-center text-sm text-neutral-400">Lade Marktplatz…</p>
|
||||
{:else if stage === 'error'}
|
||||
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
<button class="ml-2 underline" onclick={loadLanding}>Erneut versuchen</button>
|
||||
</p>
|
||||
{:else if stage === 'search'}
|
||||
<section>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-neutral-300">
|
||||
{searchTotal} Treffer für „{searchQuery}"
|
||||
</h2>
|
||||
<button class="text-xs text-neutral-500 hover:text-neutral-200" onclick={loadLanding}>
|
||||
Zurück
|
||||
</button>
|
||||
</div>
|
||||
<DeckGrid decks={searchResults} emptyText="Keine Decks gefunden." />
|
||||
</section>
|
||||
{:else if stage === 'landing'}
|
||||
{#if featured.length > 0}
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-3 text-sm font-medium text-neutral-300">
|
||||
🛡️ Featured · vom Mana-Verein empfohlen
|
||||
</h2>
|
||||
<DeckGrid decks={featured} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-medium text-neutral-300">📈 Trending · letzte 7 Tage</h2>
|
||||
<DeckGrid decks={trending} emptyText="Noch keine Trends — sei der/die Erste mit einem Public-Deck." />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<p class="mt-12 text-center text-xs text-neutral-600">
|
||||
<a href="/" class="hover:text-neutral-300">← Eigene Decks</a>
|
||||
</p>
|
||||
</main>
|
||||
135
apps/cards/apps/web/src/routes/u/[slug]/+page.svelte
Normal file
135
apps/cards/apps/web/src/routes/u/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { cardsApi, CardsApiError, type PublicAuthor, type DeckSummary } from '$lib/api/cards-api';
|
||||
import DeckGrid from '$lib/components/DeckGrid.svelte';
|
||||
|
||||
const slug = $derived(page.params.slug as string);
|
||||
|
||||
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
|
||||
let author = $state<PublicAuthor | null>(null);
|
||||
let decks = $state<DeckSummary[]>([]);
|
||||
let following = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!slug) return;
|
||||
load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
stage = 'loading';
|
||||
try {
|
||||
const [a, d] = await Promise.all([
|
||||
cardsApi.authors.bySlug(slug),
|
||||
cardsApi.explore.browse({ author: slug, sort: 'recent', limit: 50 }),
|
||||
]);
|
||||
author = a;
|
||||
decks = d.items;
|
||||
stage = 'ok';
|
||||
} catch (e) {
|
||||
if (e instanceof CardsApiError && e.status === 404) {
|
||||
stage = 'not-found';
|
||||
return;
|
||||
}
|
||||
error = (e as Error).message;
|
||||
stage = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
if (following) {
|
||||
await cardsApi.follows.unfollow(slug);
|
||||
following = false;
|
||||
} else {
|
||||
await cardsApi.follows.follow(slug);
|
||||
following = true;
|
||||
}
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{author?.displayName ?? '@' + slug} — Cards</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-3xl px-6 py-8">
|
||||
{#if stage === 'loading'}
|
||||
<p class="py-12 text-center text-sm text-neutral-400">Lade Profil…</p>
|
||||
{:else if stage === 'not-found'}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
|
||||
Profil <code class="rounded bg-neutral-800 px-1">@{slug}</code> existiert nicht.
|
||||
</p>
|
||||
{:else if stage === 'error'}
|
||||
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
{:else if author}
|
||||
<header class="mb-6 flex items-start gap-4">
|
||||
{#if author.avatarUrl}
|
||||
<img
|
||||
src={author.avatarUrl}
|
||||
alt=""
|
||||
class="h-16 w-16 rounded-full border border-neutral-800 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border border-neutral-800 bg-neutral-900 text-xl font-semibold text-neutral-400"
|
||||
>
|
||||
{author.displayName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold">{author.displayName}</h1>
|
||||
{#if author.verifiedMana}
|
||||
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-300">
|
||||
🛡️ Mana
|
||||
</span>
|
||||
{/if}
|
||||
{#if author.verifiedCommunity}
|
||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
|
||||
⭐ Community
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">
|
||||
@{author.slug} · seit {new Date(author.joinedAt).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</p>
|
||||
{#if author.bio}
|
||||
<p class="mt-2 text-sm text-neutral-300">{author.bio}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if authStore.isAuthenticated}
|
||||
<button
|
||||
class="rounded-lg border border-indigo-500/40 px-3 py-1.5 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
|
||||
onclick={toggleFollow}
|
||||
disabled={busy}
|
||||
>
|
||||
{following ? 'Entfolgen' : 'Folgen'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<h2 class="mb-3 text-sm font-medium text-neutral-300">
|
||||
{decks.length} {decks.length === 1 ? 'Deck' : 'Decks'}
|
||||
</h2>
|
||||
<DeckGrid {decks} emptyText="Dieser Author hat noch keine Decks veröffentlicht." />
|
||||
{/if}
|
||||
|
||||
<p class="mt-12 text-center text-xs text-neutral-600">
|
||||
<a href="/explore" class="hover:text-neutral-300">← Marktplatz</a>
|
||||
</p>
|
||||
</main>
|
||||
Loading…
Add table
Add a link
Reference in a new issue