refactor(marketplace): UI-Verbesserungen, MarketplaceDeckStack, Explore-Icons
- DeckListGrid: überarbeitetes Layout - EmptyState + SkeletonGrid: aufgeräumt - Neuer MarketplaceDeckStack für Marketplace-Karten-Darstellung - Explore: Icons (Fire, Star, MagnifyingGlass, Books) + Header-Cleanup - me/forks + me/subscribed: kleinere Korrekturen - docs/deck_ideas: initiale Ideen-Sammlung Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1f1abf3c4f
commit
0c68186563
8 changed files with 451 additions and 101 deletions
|
|
@ -1,9 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { DeckListEntry } from '$lib/api/marketplace.ts';
|
||||
import type { DeckCategoryId } from '@cards/domain';
|
||||
import { DECK_CATEGORY_IDS } from '@cards/domain';
|
||||
import AuthorBadge from './AuthorBadge.svelte';
|
||||
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
|
||||
import MarketplaceDeckStack from './MarketplaceDeckStack.svelte';
|
||||
|
||||
interface Props {
|
||||
items: DeckListEntry[];
|
||||
|
|
@ -11,83 +8,82 @@
|
|||
}
|
||||
|
||||
const { items, emptyMessage = 'Noch keine Decks gefunden.' }: Props = $props();
|
||||
|
||||
function isValidCategory(c: string | null): c is DeckCategoryId {
|
||||
return c !== null && (DECK_CATEGORY_IDS as readonly string[]).includes(c);
|
||||
}
|
||||
|
||||
function languageLabel(code: string | null): string {
|
||||
if (!code) return '';
|
||||
const map: Record<string, string> = { de: 'Deutsch', en: 'English', es: 'Español', fr: 'Français' };
|
||||
return map[code] ?? code.toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
<p class="empty">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ul class="deck-row" aria-label="Decks">
|
||||
{#each items as deck (deck.slug)}
|
||||
<li
|
||||
class="group relative rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary))]"
|
||||
>
|
||||
<a href="/d/{deck.slug}" class="block">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="truncate font-medium">{deck.title}</h3>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
{#if isValidCategory(deck.category)}
|
||||
<span class="text-[hsl(var(--color-muted-foreground))]" aria-hidden="true">
|
||||
<DeckCategoryIcon category={deck.category} size={16} weight="duotone" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if deck.is_featured}
|
||||
<span
|
||||
class="rounded-full bg-[hsl(var(--color-primary))]/15 px-2 py-0.5 text-[10px] font-medium text-[hsl(var(--color-primary))]"
|
||||
title="Editorial Pick"
|
||||
>
|
||||
★ Featured
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if deck.description}
|
||||
<p
|
||||
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--color-muted-foreground))]"
|
||||
>
|
||||
{deck.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<AuthorBadge
|
||||
slug={deck.owner.slug}
|
||||
displayName={deck.owner.display_name}
|
||||
verifiedMana={deck.owner.verified_mana}
|
||||
verifiedCommunity={deck.owner.verified_community}
|
||||
size="sm"
|
||||
/>
|
||||
<span>·</span>
|
||||
<span title="Karten">{deck.card_count} 🃏</span>
|
||||
<span>·</span>
|
||||
<span title="Stars">{deck.star_count} ★</span>
|
||||
{#if deck.subscriber_count > 0}
|
||||
<span>·</span>
|
||||
<span title="Subscribers">{deck.subscriber_count} ↩︎</span>
|
||||
{/if}
|
||||
{#if deck.language}
|
||||
<span>·</span>
|
||||
<span class="uppercase">{languageLabel(deck.language)}</span>
|
||||
{/if}
|
||||
{#if deck.price_credits > 0}
|
||||
<span>·</span>
|
||||
<span class="font-medium text-[hsl(var(--color-primary))]">
|
||||
{deck.price_credits} Credits
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<li class="deck-item">
|
||||
<MarketplaceDeckStack {deck} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.deck-row {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-block: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
/* snap-padding entspricht dem ::before-Spacer */
|
||||
scroll-padding-inline-start: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--color-border)) transparent;
|
||||
|
||||
/* Full-bleed: break out of main's max-width + padding */
|
||||
width: 100dvw;
|
||||
margin-left: calc(50% - 50dvw);
|
||||
}
|
||||
|
||||
/* Flex-Spacer am Anfang schafft zuverlässig den initialen Einzug —
|
||||
padding-inline-start auf scroll-containern ist browser-inkonsistent */
|
||||
.deck-row::before {
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
|
||||
}
|
||||
|
||||
/* Trailing spacer damit letzte Karte nicht am rechten Rand klebt */
|
||||
.deck-row::after {
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
|
||||
}
|
||||
|
||||
.deck-row::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.deck-row::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.deck-row::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.deck-item {
|
||||
flex: 0 0 auto;
|
||||
width: 16rem;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
icon?: Component;
|
||||
title: string;
|
||||
description?: string;
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
}
|
||||
|
||||
const { icon, title, description, ctaHref, ctaLabel }: Props = $props();
|
||||
const { icon: IconComponent, title, description, ctaHref, ctaLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]/40 p-10 text-center"
|
||||
>
|
||||
{#if icon}
|
||||
<div class="mx-auto mb-3 text-4xl" aria-hidden="true">{icon}</div>
|
||||
{#if IconComponent}
|
||||
<div class="icon-wrap" aria-hidden="true">
|
||||
<IconComponent size={40} weight="duotone" />
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="text-lg font-medium">{title}</h3>
|
||||
{#if description}
|
||||
|
|
@ -31,3 +35,12 @@
|
|||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.icon-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
<script lang="ts">
|
||||
import type { DeckListEntry } from '$lib/api/marketplace.ts';
|
||||
import type { DeckCategoryId } from '@cards/domain';
|
||||
import { DECK_CATEGORY_IDS } from '@cards/domain';
|
||||
import { stackLayers, deterministicRandoms } from '$lib/utils/deck-tilt';
|
||||
import { Star } from '@mana/shared-icons';
|
||||
import CardSurface from '$lib/components/CardSurface.svelte';
|
||||
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
deck: DeckListEntry;
|
||||
}
|
||||
|
||||
let { deck }: Props = $props();
|
||||
|
||||
const layers = $derived(stackLayers(deck.slug, 3));
|
||||
|
||||
function isValidCategory(c: string | null): c is DeckCategoryId {
|
||||
return c !== null && (DECK_CATEGORY_IDS as readonly string[]).includes(c);
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<DeckCategoryId, string> = {
|
||||
language: '#3B82F6',
|
||||
medicine: '#EF4444',
|
||||
science: '#22C55E',
|
||||
math: '#6366F1',
|
||||
history: '#F59E0B',
|
||||
law: '#64748B',
|
||||
technology: '#06B6D4',
|
||||
arts: '#A855F7',
|
||||
music: '#EC4899',
|
||||
sport: '#16A34A',
|
||||
other: '#9CA3AF',
|
||||
};
|
||||
|
||||
// Fallback: deterministisch aus slug-Hash, aus einem freundlichen Palette
|
||||
const FALLBACK_PALETTE = ['#3B82F6','#22C55E','#F59E0B','#A855F7','#06B6D4','#EC4899','#EF4444'];
|
||||
|
||||
const accentColor = $derived((): string => {
|
||||
if (isValidCategory(deck.category)) return CATEGORY_COLORS[deck.category];
|
||||
const [r] = deterministicRandoms(deck.slug, 1);
|
||||
return FALLBACK_PALETTE[Math.floor(r * FALLBACK_PALETTE.length)];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stack-wrap">
|
||||
{#each layers as layer, i (i)}
|
||||
<div
|
||||
class="layer"
|
||||
style:transform="translate({layer.dx}px, {layer.dy}px) rotate({layer.tilt}deg)"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<CardSurface
|
||||
size="md"
|
||||
as="a"
|
||||
href="/d/{deck.slug}"
|
||||
ariaLabel="{deck.title} · {deck.card_count} Karten"
|
||||
colorAccent={accentColor()}
|
||||
class="cover"
|
||||
>
|
||||
<div class="cover-inner">
|
||||
<div class="cover-corner" aria-hidden="true">
|
||||
{#if isValidCategory(deck.category)}
|
||||
<DeckCategoryIcon category={deck.category} size={20} color={accentColor()} weight="duotone" />
|
||||
{/if}
|
||||
{#if deck.is_featured}
|
||||
<span class="featured-badge" title="Editorial Pick">
|
||||
<Star size={14} weight="fill" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cover-body">
|
||||
<h2 class="cover-title">{deck.title}</h2>
|
||||
{#if deck.description}
|
||||
<p class="cover-desc">{deck.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cover-meta">
|
||||
<span class="meta-count">{deck.card_count} Karten</span>
|
||||
{#if deck.star_count > 0}
|
||||
<span class="meta-stars">
|
||||
<Star size={11} weight="fill" />{deck.star_count}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardSurface>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stack-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 18rem;
|
||||
aspect-ratio: 5 / 7;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
|
||||
}
|
||||
|
||||
.cover-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 1rem 1.125rem 1.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-corner {
|
||||
position: absolute;
|
||||
top: 0.875rem;
|
||||
right: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.featured-badge {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cover-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.875rem;
|
||||
padding: 2.5rem 0.5rem 0 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-desc {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cover-meta {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta-stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,19 +6,43 @@
|
|||
const { count = 6 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true" aria-label="Lade Decks">
|
||||
<ul class="skeleton-row" aria-busy="true" aria-label="Lade Decks">
|
||||
{#each Array.from({ length: count }) as _, i (i)}
|
||||
<li
|
||||
class="animate-pulse rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
|
||||
>
|
||||
<div class="h-4 w-3/4 rounded bg-[hsl(var(--color-border))]"></div>
|
||||
<div class="mt-2 h-3 w-full rounded bg-[hsl(var(--color-border))]/60"></div>
|
||||
<div class="mt-1 h-3 w-5/6 rounded bg-[hsl(var(--color-border))]/60"></div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<div class="h-2 w-16 rounded bg-[hsl(var(--color-border))]/40"></div>
|
||||
<div class="h-2 w-12 rounded bg-[hsl(var(--color-border))]/40"></div>
|
||||
<div class="h-2 w-20 rounded bg-[hsl(var(--color-border))]/40"></div>
|
||||
</div>
|
||||
<li class="skeleton-item animate-pulse">
|
||||
<div class="skeleton-card"></div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.skeleton-row {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-block: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
width: 100dvw;
|
||||
margin-left: calc(50% - 50dvw);
|
||||
}
|
||||
|
||||
.skeleton-row::before {
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
flex: 0 0 auto;
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
width: 100%;
|
||||
aspect-ratio: 5 / 7;
|
||||
border-radius: 0.875rem;
|
||||
background: hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
|
||||
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { Fire, Star, MagnifyingGlass, Books } from '@mana/shared-icons';
|
||||
|
||||
let featured = $state<DeckListEntry[]>([]);
|
||||
let trending = $state<DeckListEntry[]>([]);
|
||||
|
|
@ -79,36 +80,32 @@
|
|||
<title>Explore · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl space-y-10">
|
||||
<div class="space-y-10">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold">Cardecky-Library</h1>
|
||||
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Decks von der Verein-Community + KI-kuratierten Cardecky-Author. Subscribe für Live-Updates,
|
||||
fork für eigenen Lern-Stand, ✏️ Verbesserungen via Pull-Request einreichen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loadingExplore}
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
|
||||
<h2 class="section-head"><Fire size={20} weight="duotone" /> Trending</h2>
|
||||
<SkeletonGrid count={6} />
|
||||
</section>
|
||||
{:else}
|
||||
{#if featured.length > 0}
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">★ Featured</h2>
|
||||
<h2 class="section-head"><Star size={20} weight="duotone" /> Featured</h2>
|
||||
<DeckListGrid items={featured} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if trending.length > 0}
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
|
||||
<h2 class="section-head"><Fire size={20} weight="duotone" /> Trending</h2>
|
||||
<DeckListGrid items={trending} />
|
||||
</section>
|
||||
{:else if !loadingExplore && featured.length === 0}
|
||||
<EmptyState
|
||||
icon="📚"
|
||||
icon={Books}
|
||||
title="Library ist noch leer"
|
||||
description="Bald gibt's hier viele Decks. Wenn du selbst publizieren willst: leg dir ein Author-Profil an."
|
||||
ctaHref="/me/published"
|
||||
|
|
@ -118,7 +115,7 @@
|
|||
{/if}
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">🔎 Stöbern</h2>
|
||||
<h2 class="section-head"><MagnifyingGlass size={20} weight="duotone" /> Stöbern</h2>
|
||||
|
||||
<form
|
||||
class="mb-4 flex flex-wrap items-end gap-3"
|
||||
|
|
@ -182,3 +179,15 @@
|
|||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { GitFork } from '@mana/shared-icons';
|
||||
|
||||
let forks = $state<Deck[]>([]);
|
||||
let loading = $state(true);
|
||||
|
|
@ -72,7 +73,7 @@
|
|||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if forks.length === 0}
|
||||
<EmptyState
|
||||
icon="🔱"
|
||||
icon={GitFork}
|
||||
title="Noch nichts geforkt"
|
||||
description={'Forks geben dir eine eigene Lern-Kopie eines Marketplace-Decks. FSRS-Reviews bleiben bei dir; Updates des Original-Authors kannst du via „Update ziehen“ einspielen.'}
|
||||
ctaHref="/explore"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { ArrowCounterClockwise } from '@mana/shared-icons';
|
||||
|
||||
let items = $state<SubscriptionEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState
|
||||
icon="↩︎"
|
||||
icon={ArrowCounterClockwise}
|
||||
title="Noch keine Abos"
|
||||
description="Subscribe einen Deck im Explore, dann siehst du hier Updates des Authors."
|
||||
ctaHref="/explore"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue