feat(decks): Edit-Icon auf Deck-Karten + Deck-Edit-Page
- DeckStack: Pencil-Icon absolut unten-rechts, erscheint beim Hover (z-index über Card, ausserhalb des <a>-Links zur Detail-Page) - Neuer Route /decks/[id]/edit: Form für Name, Beschreibung, Farbe - i18n deck_edit keys (de + en) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9754718157
commit
03ec7e7b3e
4 changed files with 298 additions and 0 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
||||||
import CardSurface from './CardSurface.svelte';
|
import CardSurface from './CardSurface.svelte';
|
||||||
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
|
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
|
||||||
|
import { PencilSimple } from '@mana/shared-icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deck: Deck;
|
deck: Deck;
|
||||||
|
|
@ -43,6 +44,16 @@
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/decks/{deck.id}/edit"
|
||||||
|
class="edit-btn"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
aria-label="Deck bearbeiten"
|
||||||
|
title="Deck bearbeiten"
|
||||||
|
>
|
||||||
|
<PencilSimple size={13} weight="bold" />
|
||||||
|
</a>
|
||||||
|
|
||||||
<CardSurface
|
<CardSurface
|
||||||
size="md"
|
size="md"
|
||||||
as={href ? 'a' : 'button'}
|
as={href ? 'a' : 'button'}
|
||||||
|
|
@ -170,4 +181,34 @@
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.625rem;
|
||||||
|
right: 0.625rem;
|
||||||
|
z-index: 10;
|
||||||
|
width: 1.625rem;
|
||||||
|
height: 1.625rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: hsl(var(--color-surface) / 0.92);
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.12s, background 0.12s, border-color 0.12s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-wrap:hover .edit-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
background: hsl(var(--color-surface));
|
||||||
|
border-color: hsl(var(--color-primary) / 0.5);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,18 @@ export const de: TranslationNode = {
|
||||||
deck_stack: {
|
deck_stack: {
|
||||||
aria_label: 'Stapel "{name}" — {cards} Karten, {due} fällig',
|
aria_label: 'Stapel "{name}" — {cards} Karten, {due} fällig',
|
||||||
},
|
},
|
||||||
|
deck_edit: {
|
||||||
|
title: 'Deck bearbeiten',
|
||||||
|
back: '← Zurück zum Deck',
|
||||||
|
name_label: 'Name',
|
||||||
|
description_label: 'Beschreibung (optional)',
|
||||||
|
color_label: 'Farbe',
|
||||||
|
save: 'Speichern',
|
||||||
|
saving: 'Speichere…',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
save_failed: 'Speichern fehlgeschlagen: {msg}',
|
||||||
|
saved: 'Deck gespeichert',
|
||||||
|
},
|
||||||
deck_new: {
|
deck_new: {
|
||||||
title: 'Neues Deck',
|
title: 'Neues Deck',
|
||||||
name_label: 'Name',
|
name_label: 'Name',
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,18 @@ export const en: TranslationNode = {
|
||||||
deck_stack: {
|
deck_stack: {
|
||||||
aria_label: 'Stack "{name}" — {cards} cards, {due} due',
|
aria_label: 'Stack "{name}" — {cards} cards, {due} due',
|
||||||
},
|
},
|
||||||
|
deck_edit: {
|
||||||
|
title: 'Edit deck',
|
||||||
|
back: '← Back to deck',
|
||||||
|
name_label: 'Name',
|
||||||
|
description_label: 'Description (optional)',
|
||||||
|
color_label: 'Color',
|
||||||
|
save: 'Save',
|
||||||
|
saving: 'Saving…',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save_failed: 'Save failed: {msg}',
|
||||||
|
saved: 'Deck saved',
|
||||||
|
},
|
||||||
deck_new: {
|
deck_new: {
|
||||||
title: 'New deck',
|
title: 'New deck',
|
||||||
name_label: 'Name',
|
name_label: 'Name',
|
||||||
|
|
|
||||||
233
apps/web/src/routes/decks/[id]/edit/+page.svelte
Normal file
233
apps/web/src/routes/decks/[id]/edit/+page.svelte
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getDeck, updateDeck } from '$lib/api/decks.ts';
|
||||||
|
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||||
|
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||||
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
|
|
||||||
|
const deckId = $derived(page.params.id ?? '');
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let color = $state('#6366f1');
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!devUser.id) { goto('/'); return; }
|
||||||
|
try {
|
||||||
|
const deck = await getDeck(deckId);
|
||||||
|
name = deck.name;
|
||||||
|
description = deck.description ?? '';
|
||||||
|
color = deck.color ?? '#6366f1';
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error((e as Error).message);
|
||||||
|
goto(`/decks/${deckId}`);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSave = $derived(!saving && name.trim().length > 0);
|
||||||
|
|
||||||
|
async function onSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSave) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await updateDeck(deckId, {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
toasts.success(t('deck_edit.saved'));
|
||||||
|
goto(`/decks/${deckId}`);
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(t('deck_edit.save_failed', { msg: (e as Error).message }));
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !loading}
|
||||||
|
<div class="page-shell">
|
||||||
|
<a href="/decks/{deckId}" class="back-link">{t('deck_edit.back')}</a>
|
||||||
|
<h1 class="page-title">{t('deck_edit.title')}</h1>
|
||||||
|
|
||||||
|
<form class="edit-form" onsubmit={onSubmit}>
|
||||||
|
<section class="form-section">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">{t('deck_edit.name_label')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Deck-Name"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">{t('deck_edit.description_label')}</span>
|
||||||
|
<textarea
|
||||||
|
bind:value={description}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Kurze Beschreibung…"
|
||||||
|
class="input"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">{t('deck_edit.color_label')}</span>
|
||||||
|
<div class="color-row">
|
||||||
|
<input type="color" bind:value={color} class="color-input" />
|
||||||
|
<span class="color-value">{color}</span>
|
||||||
|
<span class="color-preview" style="background:{color}" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={!canSave} class="btn-primary">
|
||||||
|
{saving ? t('deck_edit.saving') : t('deck_edit.save')}
|
||||||
|
</button>
|
||||||
|
<a href="/decks/{deckId}" class="btn-ghost">{t('deck_edit.cancel')}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-shell {
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: hsl(var(--color-foreground)); }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
background: hsl(var(--color-card));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-value {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue