i18n(comic+quiz): translate picker/character-detail/play-view via $_()

- comic/components/CharacterPicker: route through comic.picker.* with
  HTML interpolation for the no-face/empty-garment alerts
- comic/views/DetailCharacterView: route through comic.character_detail.*
  + dynamic comic.styles.<id>; drops unused STYLE_LABELS import
- quiz/PlayView: route through quiz.play_view.* (back/empty/result/play
  all consolidated)

Baseline 869 → 851 (-18).
This commit is contained in:
Till JS 2026-04-27 18:47:37 +02:00
parent 5d9dc80662
commit a842537191
5 changed files with 111 additions and 78 deletions

View file

@ -18,6 +18,7 @@
import { useAllGarments } from '$lib/modules/wardrobe/queries';
import { garmentPhotoUrl } from '$lib/modules/wardrobe/api/media-url';
import type { Garment } from '$lib/modules/wardrobe/types';
import { _ } from 'svelte-i18n';
interface Props {
value: string[];
@ -108,33 +109,30 @@
<div class="space-y-3">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Protagonist
{$_('comic.picker.section_title')}
</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
Dein Gesicht ist Pflicht. Body-Ref und bis zu {MAX_GARMENTS} Kostüm-Fotos sind optional — klicke
ein Bild oder das ✕, um es wieder zu entfernen.
{$_('comic.picker.section_hint', { values: { max: MAX_GARMENTS } })}
</p>
</div>
<div class="flex flex-wrap items-start gap-2">
<!-- Face ref tile — mandatory, not deselectable. Small "Pflicht"-
badge makes the locked state explicit so the user doesn't
hunt for a remove button that doesn't exist. -->
<!-- Face ref tile — mandatory, not deselectable. -->
<div class="flex flex-col items-center gap-1">
{#if face?.publicUrl}
<div
class="relative h-20 w-20 overflow-hidden rounded-md border-2 border-primary/40"
title="Face-Ref ist Pflicht — kann nicht entfernt werden"
title={$_('comic.picker.face_required_title')}
>
<img
src={face.thumbnailUrl ?? face.publicUrl}
alt="Face-Ref"
alt={$_('comic.picker.face_alt')}
class="h-full w-full object-cover"
/>
<span
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wider text-white"
>
Pflicht
{$_('comic.picker.face_required_badge')}
</span>
</div>
{:else}
@ -142,10 +140,12 @@
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/50 text-[10px] text-muted-foreground"
>
<UserCircle size={20} />
<span>Face fehlt</span>
<span>{$_('comic.picker.face_missing')}</span>
</div>
{/if}
<span class="text-[10px] font-medium text-muted-foreground">Face</span>
<span class="text-[10px] font-medium text-muted-foreground"
>{$_('comic.picker.face_label')}</span
>
</div>
<!-- Body ref tile — optional toggle. Two states need clear visual
@ -165,11 +165,11 @@
? 'border-primary shadow-sm shadow-primary/20'
: 'border-border opacity-60 hover:border-primary/50 hover:opacity-100 hover:shadow-sm'}"
aria-pressed={bodyInValue}
title={bodyInValue ? 'Klick zum Entfernen' : 'Klick zum Hinzufügen'}
title={bodyInValue ? $_('comic.picker.toggle_remove') : $_('comic.picker.toggle_add')}
>
<img
src={body.thumbnailUrl ?? body.publicUrl}
alt="Body-Ref"
alt={$_('comic.picker.body_alt')}
class="h-full w-full object-cover"
/>
{#if !bodyInValue}
@ -189,13 +189,15 @@
{:else}
<div
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/30 text-[10px] text-muted-foreground"
title="Kein Body-Ref im aktiven Space"
title={$_('comic.picker.body_no_in_space')}
>
<UserCircle size={18} />
<span>Body fehlt</span>
<span>{$_('comic.picker.body_missing')}</span>
</div>
{/if}
<span class="text-[10px] font-medium text-muted-foreground">Body</span>
<span class="text-[10px] font-medium text-muted-foreground"
>{$_('comic.picker.body_label')}</span
>
</div>
<!-- Garment tiles (picked). Whole tile is also clickable to
@ -210,8 +212,8 @@
{disabled}
onclick={() => mediaId && removeGarment(mediaId)}
class="group relative h-20 w-20 overflow-hidden rounded-md border-2 border-primary/40 shadow-sm transition-all active:translate-y-px hover:border-error/60"
aria-label={`${g.name} entfernen`}
title="Klick zum Entfernen"
aria-label={$_('comic.picker.garment_remove_aria', { values: { name: g.name } })}
title={$_('comic.picker.toggle_remove')}
>
{#if mediaId}
<img
@ -247,7 +249,7 @@
aria-expanded={showGarmentPicker}
>
<Plus size={16} />
<span class="text-[10px] font-medium">Kostüm</span>
<span class="text-[10px] font-medium">{$_('comic.picker.garment_label')}</span>
</button>
<span class="text-[10px] text-muted-foreground">
{garmentIdsInValue.length}/{MAX_GARMENTS}
@ -260,21 +262,21 @@
{#if showGarmentPicker}
<div class="rounded-lg border border-border bg-muted/30 p-3">
<div class="mb-2 flex items-center justify-between">
<h4 class="text-xs font-semibold text-foreground">Kostüm aus dem Schrank wählen</h4>
<h4 class="text-xs font-semibold text-foreground">
{$_('comic.picker.garment_picker_title')}
</h4>
<button
type="button"
onclick={() => (showGarmentPicker = false)}
class="text-xs text-muted-foreground hover:text-foreground"
>
Schließen
{$_('comic.picker.garment_picker_close')}
</button>
</div>
{#if availableGarments.length === 0}
<p class="text-xs text-muted-foreground">
Keine weiteren Kleidungsstücke verfügbar — lade welche in <a
href="/wardrobe"
class="text-primary hover:underline">/wardrobe</a
> hoch.
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html $_('comic.picker.garment_picker_empty_html')}
</p>
{:else}
<div class="grid max-h-48 grid-cols-4 gap-2 overflow-y-auto sm:grid-cols-6">
@ -307,14 +309,13 @@
{#if !hasFace}
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
Kein Gesichtsbild in diesem Space. Lade eins in
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a>
hoch — ohne Face-Ref kein Comic.
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html $_('comic.picker.no_face_alert_html')}
</div>
{:else if !hasBody}
<p class="text-xs text-muted-foreground">
<TShirt size={12} class="inline" /> Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen
soll.
<TShirt size={12} class="inline" />
{$_('comic.picker.body_tip')}
</p>
{/if}
</div>

View file

@ -8,9 +8,9 @@
import { ArrowLeft, Archive, Heart, Plus, Sparkle, Trash } from '@mana/shared-icons';
import { comicCharactersStore } from '../stores/characters.svelte';
import { useCharacter } from '../queries';
import { STYLE_LABELS } from '../constants';
import VariantTile from '../components/VariantTile.svelte';
import CharacterBuilder from '../components/CharacterBuilder.svelte';
import { _ } from 'svelte-i18n';
interface Props {
id: string;
@ -36,7 +36,14 @@
async function handleDelete() {
if (!character) return;
if (!confirm(`Character "${character.name}" wirklich löschen?`)) return;
if (
!confirm(
$_('comic.character_detail.confirm_delete_character', {
values: { name: character.name },
})
)
)
return;
await comicCharactersStore.deleteCharacter(character.id);
await goto('/comic/character');
}
@ -48,12 +55,7 @@
async function handleRemove(variantId: string) {
if (!character) return;
if (
!confirm(
'Variante aus dem Character entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden.'
)
)
return;
if (!confirm($_('comic.character_detail.confirm_remove_variant'))) return;
await comicCharactersStore.removeVariant(character.id, variantId);
}
</script>
@ -63,20 +65,24 @@
<a
href="/comic/character"
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
aria-label="Zurück zu Characters"
aria-label={$_('comic.character_detail.back_aria')}
>
<ArrowLeft size={16} />
</a>
<span class="text-muted-foreground">Comic · Characters</span>
<span class="text-muted-foreground">{$_('comic.character_detail.breadcrumb')}</span>
</nav>
{#if !character}
{#if character$.loading}
<p class="text-sm text-muted-foreground">Lädt…</p>
<p class="text-sm text-muted-foreground">{$_('comic.character_detail.loading')}</p>
{:else}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<p class="text-sm font-medium text-foreground">Character nicht gefunden.</p>
<p class="mt-1 text-sm text-muted-foreground">Gelöscht oder in einem anderen Space.</p>
<p class="text-sm font-medium text-foreground">
{$_('comic.character_detail.not_found')}
</p>
<p class="mt-1 text-sm text-muted-foreground">
{$_('comic.character_detail.not_found_hint')}
</p>
</div>
{/if}
{:else}
@ -87,15 +93,20 @@
<h1 class="truncate text-lg font-semibold text-foreground">{character.name}</h1>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
{STYLE_LABELS[character.style].de}
{$_('comic.styles.' + character.style)}
</span>
<span>
{character.variantMediaIds.length}
{character.variantMediaIds.length === 1 ? 'Variante' : 'Varianten'}
{character.variantMediaIds.length === 1
? $_('comic.character_detail.variant_one', {
values: { n: character.variantMediaIds.length },
})
: $_('comic.character_detail.variant_other', {
values: { n: character.variantMediaIds.length },
})}
</span>
{#if !character.pinnedVariantId && character.variantMediaIds.length > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 font-medium text-amber-700"
>Pin offen</span
>{$_('comic.character_detail.pin_open')}</span
>
{/if}
</div>
@ -103,8 +114,12 @@
<button
type="button"
onclick={handleToggleFavorite}
aria-label={character.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
title={character.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-label={character.isFavorite
? $_('comic.character_detail.favorite_remove')
: $_('comic.character_detail.favorite_set')}
title={character.isFavorite
? $_('comic.character_detail.favorite_remove')
: $_('comic.character_detail.favorite_set')}
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {character.isFavorite
? 'text-rose-500 hover:bg-rose-500/10'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
@ -119,7 +134,7 @@
{#if character.addPrompt}
<div class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<strong class="text-foreground">Prompt-Add:</strong>
<strong class="text-foreground">{$_('comic.character_detail.prompt_add_label')}</strong>
{character.addPrompt}
</div>
{/if}
@ -129,7 +144,7 @@
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Varianten
{$_('comic.character_detail.section_variants')}
</h2>
{#if !showBuilder && !character.isArchived}
<button
@ -138,7 +153,7 @@
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={12} />
Mehr Varianten
{$_('comic.character_detail.action_more_variants')}
</button>
{/if}
</div>
@ -147,10 +162,12 @@
<div
class="rounded-2xl border border-dashed border-border bg-background/50 p-6 text-center"
>
<p class="text-sm font-medium text-foreground">Noch keine Varianten.</p>
<p class="text-sm font-medium text-foreground">
{$_('comic.character_detail.empty_variants_title')}
</p>
<p class="mt-1 text-sm text-muted-foreground">
Klick oben rechts auf <strong class="text-foreground">+ Mehr Varianten</strong>, um die
ersten 4 zu generieren.
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html $_('comic.character_detail.empty_variants_hint_html')}
</p>
</div>
{:else}
@ -190,7 +207,9 @@
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
<Archive size={14} />
{character.isArchived ? 'Wieder aktiv' : 'Archivieren'}
{character.isArchived
? $_('comic.character_detail.unarchive')
: $_('comic.character_detail.archive')}
</button>
<button
type="button"
@ -198,7 +217,7 @@
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
>
<Trash size={14} />
Löschen
{$_('comic.character_detail.delete')}
</button>
</div>
@ -206,8 +225,8 @@
<p
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
>
<Sparkle size={12} class="inline" /> Archivierter Character — keine Variant-Generierung möglich,
bis wieder aktiviert.
<Sparkle size={12} class="inline" />
{$_('comic.character_detail.archived_hint')}
</p>
{/if}
{/if}

View file

@ -8,6 +8,7 @@
import { attemptsStore } from './stores/attempts.svelte';
import type { AttemptAnswer } from './types';
import { ArrowLeft, Check, X } from '@mana/shared-icons';
import { _ } from 'svelte-i18n';
interface Props {
quizId: string;
@ -108,8 +109,9 @@
<div class="wrap">
<header class="header">
<button class="back" onclick={() => goto('/quiz')} aria-label="Zurück">
<ArrowLeft size={18} /> Quiz
<button class="back" onclick={() => goto('/quiz')} aria-label={$_('quiz.play_view.back_aria')}>
<ArrowLeft size={18} />
{$_('quiz.play_view.back_label')}
</button>
{#if quiz}
<span class="title">{quiz.title}</span>
@ -120,14 +122,18 @@
</header>
{#if !quiz}
<p class="empty">Quiz nicht gefunden.</p>
<p class="empty">{$_('quiz.play_view.empty_quiz')}</p>
{:else if total === 0}
<p class="empty">Dieses Quiz hat noch keine Fragen.</p>
<p class="empty">{$_('quiz.play_view.empty_no_questions')}</p>
{:else if finished}
<section class="result">
<div class="score">
<span class="score-num">{scorePct}%</span>
<span class="score-sub">{correctCount} von {total} richtig</span>
<span class="score-sub"
>{$_('quiz.play_view.score_summary', {
values: { correct: correctCount, total },
})}</span
>
</div>
<ol class="review">
{#each questions as q, i (q.id)}
@ -142,15 +148,18 @@
</div>
{#if q.type === 'text'}
<p class="review-line">
Deine Antwort: <strong>{ans?.textAnswer || '—'}</strong>
{$_('quiz.play_view.review_your_answer')}<strong
>{ans?.textAnswer || $_('quiz.play_view.placeholder_review_dash')}</strong
>
</p>
{#if !ans?.correct}
<p class="review-line">Richtig: <strong>{q.options[0]?.text}</strong></p>
<p class="review-line">
{$_('quiz.play_view.review_correct')}<strong>{q.options[0]?.text}</strong>
</p>
{/if}
{:else}
<p class="review-line">
Richtig:
<strong>
{$_('quiz.play_view.review_correct')}<strong>
{q.options
.filter((o) => o.isCorrect)
.map((o) => o.text)
@ -162,8 +171,10 @@
{/each}
</ol>
<div class="result-actions">
<button class="secondary-btn" onclick={() => goto('/quiz')}>Zurück zur Liste</button>
<button class="primary-btn" onclick={restart}>Nochmal spielen</button>
<button class="secondary-btn" onclick={() => goto('/quiz')}
>{$_('quiz.play_view.action_back_to_list')}</button
>
<button class="primary-btn" onclick={restart}>{$_('quiz.play_view.action_replay')}</button>
</div>
</section>
{:else if current}
@ -176,14 +187,16 @@
type="text"
bind:value={textInput}
disabled={revealed}
placeholder="Deine Antwort"
placeholder={$_('quiz.play_view.placeholder_text_answer')}
/>
{#if revealed}
<p class="feedback" class:ok={answers.at(-1)?.correct}>
{#if answers.at(-1)?.correct}
<Check size={14} weight="bold" /> Richtig!
<Check size={14} weight="bold" />
{$_('quiz.play_view.feedback_correct')}
{:else}
<X size={14} weight="bold" /> Richtige Antwort:
<X size={14} weight="bold" />
{$_('quiz.play_view.feedback_incorrect_label')}
<strong>{current.options[0]?.text}</strong>
{/if}
</p>
@ -221,11 +234,13 @@
<div class="play-actions">
{#if !revealed}
<button class="primary-btn" disabled={!canAnswer} onclick={reveal}>
Antwort prüfen
{$_('quiz.play_view.action_check')}
</button>
{:else}
<button class="primary-btn" onclick={next}>
{currentIndex + 1 >= total ? 'Ergebnis ansehen' : 'Weiter'}
{currentIndex + 1 >= total
? $_('quiz.play_view.action_view_result')
: $_('quiz.play_view.action_next')}
</button>
{/if}
</div>

View file

@ -74,7 +74,6 @@
"apps/mana/apps/web/src/lib/modules/comic/components/BatchPanelEditor.svelte": 2,
"apps/mana/apps/web/src/lib/modules/comic/components/CharacterBuilder.svelte": 4,
"apps/mana/apps/web/src/lib/modules/comic/components/CharacterCard.svelte": 1,
"apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte": 6,
"apps/mana/apps/web/src/lib/modules/comic/components/CharacterRefPicker.svelte": 2,
"apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte": 3,
"apps/mana/apps/web/src/lib/modules/comic/components/PanelModelPicker.svelte": 1,
@ -86,7 +85,6 @@
"apps/mana/apps/web/src/lib/modules/comic/components/VariantTile.svelte": 1,
"apps/mana/apps/web/src/lib/modules/comic/ListView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/comic/views/CharactersView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/comic/views/DetailCharacterView.svelte": 6,
"apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte": 2,
"apps/mana/apps/web/src/lib/modules/community/components/ItemCard.svelte": 1,
"apps/mana/apps/web/src/lib/modules/community/views/DetailView.svelte": 1,
@ -150,7 +148,6 @@
"apps/mana/apps/web/src/lib/modules/questions/ListView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/questions/views/DetailView.svelte": 6,
"apps/mana/apps/web/src/lib/modules/quiz/ListView.svelte": 5,
"apps/mana/apps/web/src/lib/modules/quiz/PlayView.svelte": 6,
"apps/mana/apps/web/src/lib/modules/quotes/views/DetailView.svelte": 2,
"apps/mana/apps/web/src/lib/modules/research-lab/components/CompareColumn.svelte": 2,
"apps/mana/apps/web/src/lib/modules/research-lab/ListView.svelte": 4,

View file

@ -8,6 +8,7 @@
"apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/broadcast/views/DetailView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/comic/views/DetailCharacterView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/credits/ListView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte": 1,