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:
parent
608b385c05
commit
a883ba87b6
1 changed files with 347 additions and 80 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue