refactor(me/published): UX-Fix — Anzeige-Name zuerst, Auto-Slug, einspaltiges Layout

- Anzeige-Name als erstes Feld, Slug wird automatisch daraus abgeleitet
  (slugify: Umlaute, Sonderzeichen, Leerzeichen → url-safe)
- Slug kann manuell überschrieben werden (slugManuallyEdited-Flag)
- Alle Felder einspaltung untereinander (kein sm:grid-cols-2 mehr)
- Seitentitel dynamisch: "Author-Profil anlegen" vs "Meine Veröffentlichungen"
- Untertitel zeigt @slug + Anzahl Decks wenn Profil existiert
- Deck-Liste überarbeitet mit meta-Zeile und "Ansehen →"-Link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:11:57 +02:00
parent 608b385c05
commit a883ba87b6

View file

@ -1,8 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import {
browseDecks,
getMyAuthorProfile,
@ -17,17 +15,15 @@
let decks = $state<DeckListEntry[]>([]);
let loading = $state(true);
let formSlug = $state('');
let formDisplayName = $state('');
let formSlug = $state('');
let formBio = $state('');
let formPseudonym = $state(false);
let saving = $state(false);
let slugManuallyEdited = $state(false);
onMount(async () => {
if (!devUser.id) {
await goto('/');
return;
}
if (!devUser.id) { await goto('/'); return; }
await load();
});
@ -36,16 +32,13 @@
try {
author = await getMyAuthorProfile();
if (author) {
formSlug = author.slug;
formDisplayName = author.display_name;
formSlug = author.slug;
formBio = author.bio ?? '';
formPseudonym = author.pseudonym;
slugManuallyEdited = true; // existing slug — don't overwrite
const result = await browseDecks({
author: author.slug,
sort: 'recent',
limit: 50,
});
const result = await browseDecks({ author: author.slug, sort: 'recent', limit: 50 });
decks = result.items;
}
} catch (e) {
@ -55,6 +48,25 @@
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
function onDisplayNameInput() {
if (!slugManuallyEdited) {
formSlug = slugify(formDisplayName);
}
}
function onSlugInput() {
slugManuallyEdited = true;
}
async function onSaveProfile(event: SubmitEvent) {
event.preventDefault();
if (!formSlug.trim() || !formDisplayName.trim()) return;
@ -63,9 +75,10 @@
author = await upsertMyAuthorProfile({
slug: formSlug.trim(),
displayName: formDisplayName.trim(),
bio: formBio || undefined,
bio: formBio.trim() || undefined,
pseudonym: formPseudonym,
});
slugManuallyEdited = true;
toasts.success('Profil gespeichert.');
if (decks.length === 0) {
const result = await browseDecks({ author: formSlug.trim(), sort: 'recent', limit: 50 });
@ -77,69 +90,86 @@
saving = false;
}
}
const pageTitle = $derived(author ? 'Meine Veröffentlichungen' : 'Author-Profil anlegen');
const pageSubtitle = $derived(
author
? `@${author.slug} · ${decks.length} Deck${decks.length !== 1 ? 's' : ''} veröffentlicht`
: 'Lege dein Author-Profil an um eigene Decks im Cardecky-Marketplace zu veröffentlichen.'
);
</script>
<svelte:head>
<title>Meine Veröffentlichungen · Cardecky</title>
<title>{pageTitle} · Cardecky</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-8">
<header>
<h1 class="text-2xl font-semibold">Meine Veröffentlichungen</h1>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
Decks, die du als Marketplace-Author published hast.
</p>
<div class="page-shell">
<header class="page-header">
<h1 class="page-title">{pageTitle}</h1>
<p class="page-subtitle">{pageSubtitle}</p>
</header>
{#if loading}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
<p class="loading">Lade…</p>
{:else}
<section
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h2 class="mb-3 text-lg font-semibold">
{author ? 'Author-Profil' : 'Author-Profil anlegen'}
</h2>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={onSaveProfile}>
<label class="block">
<span class="text-sm font-medium">Slug (url-safe)</span>
<input
type="text"
bind:value={formSlug}
required
pattern="[a-z0-9](?:[a-z0-9-]+[a-z0-9])?"
maxlength="60"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm font-mono"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Anzeige-Name</span>
<!-- Author profile form -->
<section class="form-section">
<h2 class="section-title">{author ? 'Author-Profil' : 'Profil einrichten'}</h2>
<form class="profile-form" onsubmit={onSaveProfile}>
<label class="field">
<span class="field-label">Anzeige-Name</span>
<input
type="text"
bind:value={formDisplayName}
oninput={onDisplayNameInput}
required
maxlength="80"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
placeholder="z. B. Till Hoffmann"
class="input"
/>
<span class="field-hint">Dein öffentlich sichtbarer Name als Author.</span>
</label>
<label class="field">
<span class="field-label">
Slug <span class="slug-preview"
>{formSlug ? `cardecky.mana.how/a/${formSlug}` : 'url-safe, z. B. till-hoffmann'}</span
>
</span>
<input
type="text"
bind:value={formSlug}
oninput={onSlugInput}
required
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
maxlength="60"
placeholder="wird automatisch vorgeschlagen"
class="input mono"
/>
</label>
<label class="block sm:col-span-2">
<span class="text-sm font-medium">Bio (optional)</span>
<label class="field">
<span class="field-label">Bio <span class="optional">(optional)</span></span>
<textarea
bind:value={formBio}
rows="2"
rows="3"
maxlength="500"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
placeholder="Kurze Vorstellung, z. B. Lehrer, Lernenthusiast, …"
class="input"
></textarea>
</label>
<label class="flex items-center gap-2 sm:col-span-2">
<input type="checkbox" bind:checked={formPseudonym} />
<span class="text-sm">Pseudonym-Modus (Klarname versteckt)</span>
<label class="checkbox-field">
<input type="checkbox" bind:checked={formPseudonym} class="checkbox" />
<span class="checkbox-label">Pseudonym-Modus — Klarname wird nicht angezeigt</span>
</label>
<div class="sm:col-span-2">
<div class="form-actions">
<button
type="submit"
disabled={saving || !formSlug || !formDisplayName}
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
disabled={saving || !formSlug.trim() || !formDisplayName.trim()}
class="btn-primary"
>
{saving ? 'Speichere…' : author ? 'Profil aktualisieren' : 'Profil anlegen'}
</button>
@ -147,40 +177,36 @@
</form>
</section>
<!-- Published decks (only when author exists) -->
{#if author}
<section>
<h2 class="mb-3 text-lg font-semibold">Decks ({decks.length})</h2>
<h2 class="section-title">
Veröffentlichte Decks
{#if decks.length > 0}<span class="count-badge">{decks.length}</span>{/if}
</h2>
{#if decks.length === 0}
<p
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]"
>
Noch nichts veröffentlicht. Decks werden über die Marketplace-API initialisiert
(z.B. via Cardecky-Skill); danach erscheinen sie hier.
</p>
{:else}
<ul class="space-y-2">
{#each decks as deck (deck.slug)}
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
<div class="flex items-start justify-between gap-3">
<div>
<a
href="/d/{deck.slug}"
class="font-medium hover:text-[hsl(var(--color-primary))]"
>
{deck.title}
</a>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{deck.card_count} Karten · {deck.star_count} ★ · {deck.subscriber_count} ↩︎ ·
{deck.license}
<div class="empty-decks">
<p class="empty-text">Noch nichts veröffentlicht.</p>
<p class="empty-hint">
Öffne ein Deck unter „Decks" und klicke auf „↑ Veröffentlichen".
</p>
</div>
{:else}
<ul class="deck-list">
{#each decks as deck (deck.slug)}
<li class="deck-item">
<div class="deck-info">
<a href="/d/{deck.slug}" class="deck-link">{deck.title}</a>
<p class="deck-meta">
{deck.card_count} Karten · {deck.star_count} ★ · {deck.subscriber_count} Abos · {deck.license}
</p>
</div>
<div class="deck-badges">
{#if deck.is_featured}
<span
class="shrink-0 rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
>
★ Featured
</span>
<span class="badge-featured">★ Featured</span>
{/if}
<a href="/d/{deck.slug}" class="deck-link-btn">Ansehen →</a>
</div>
</li>
{/each}
@ -190,3 +216,244 @@
{/if}
{/if}
</div>
<style>
.page-shell {
max-width: 36rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.page-header { display: flex; flex-direction: column; gap: 0.25rem; }
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.page-subtitle {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.loading {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
/* Form section */
.form-section {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.25rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1.125rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.25rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
font-size: 0.75rem;
font-weight: 600;
}
.profile-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.slug-preview {
font-size: 0.75rem;
font-weight: 400;
color: hsl(var(--color-muted-foreground));
font-family: ui-monospace, monospace;
}
.optional {
font-size: 0.75rem;
font-weight: 400;
color: hsl(var(--color-muted-foreground));
}
.field-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.input {
display: block;
width: 100%;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
padding: 0.5rem 0.625rem;
font: inherit;
font-size: 0.875rem;
transition: border-color 0.12s, box-shadow 0.12s;
resize: vertical;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.6);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input.mono { font-family: ui-monospace, 'Cascadia Code', monospace; }
.checkbox-field {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox {
width: 1rem;
height: 1rem;
cursor: pointer;
flex-shrink: 0;
}
.checkbox-label {
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.form-actions { padding-top: 0.25rem; }
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.5rem 1.125rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font: inherit;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: background-color 0.12s;
}
.btn-primary:hover:not(:disabled) { background: hsl(var(--color-primary) / 0.88); }
.btn-primary:disabled { opacity: 0.45; cursor: default; }
/* Deck list */
.empty-decks {
border: 1px dashed hsl(var(--color-border));
border-radius: 0.75rem;
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.empty-text {
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
margin: 0;
}
.empty-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.deck-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.deck-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
}
.deck-info { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.deck-link {
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-foreground));
text-decoration: none;
}
.deck-link:hover { color: hsl(var(--color-primary)); }
.deck-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.deck-badges {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.badge-featured {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
white-space: nowrap;
}
.deck-link-btn {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
white-space: nowrap;
}
.deck-link-btn:hover { color: hsl(var(--color-primary)); }
</style>