polish(wardrobe): garment-detail cosmetic pass + slug-cleanup on upload

Four small UI tweaks that came out of reviewing the garment-detail
screenshot against the workbench chrome:

1. Duplicate "Kleiderschrank" label — the ModuleShell header above
   DetailGarmentView already renders a back-arrow and the app title.
   The inner `<nav>` with a second arrow + text was rendering it all
   a second time. Drop the inner breadcrumb; ArrowLeft import along
   with it.

2. Raw SKU-slug as default garment name — the old
   `stripExt(file.name)` produced labels like
   `17390-gestreiftes-herren-t-shirt-aus-baumwolle-17390-2-w`. New
   `prettifyUploadName` helper:
   - drops the extension
   - replaces `-`/`_` with spaces
   - strips pure-digit tokens of length ≥ 4 (SKU shape) but keeps
     short alphanumerics like `4xl` / `w38`
   - title-cases each remaining word, rebuilding hyphens
     (`t-shirt` → `T-Shirt`, `v-neck` → `V-Neck`)
   - clamps to 80 chars on a word boundary
   GridView's ingestFiles now passes the prettified name into the
   createGarment write. User still edits on the detail page for
   anything that needs nuance.

3. Two-line CTA with Credits subtitle. The button used to read
   `Anprobieren · 10 Credits` on one line; on a narrow workbench
   card the mittelpunkt between label and cost was visually thin
   and read like a strikethrough. Split into a main label + small
   opacity-75 subtitle so the credit figure is clearly secondary
   info, not a decorated part of the CTA text. Applied to both
   GarmentTryOnButton and TryOnButton.

4. Redundant microcopy under section headers — "Einzelstück auf dir
   gerendert" under ANPROBEN and "Komposition öffnen" under IN
   OUTFITS repeated what the section title and the clickable cards
   already signalled. Remove both.

No behaviour changes, no schema, no API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:24:07 +02:00
parent 87b567eec9
commit 05b2209232
7 changed files with 116 additions and 45 deletions

View file

@ -167,16 +167,21 @@
type="button"
onclick={handleClick}
disabled={running || !canTryOn}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-full flex-col items-center justify-center gap-0.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if running}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Rendere…
<span class="flex items-center gap-2">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></span>
Rendere…
</span>
{:else}
<Sparkle size={16} weight="fill" />
An mir anprobieren · {estimatedCredits} Credits
<span class="flex items-center gap-2">
<Sparkle size={16} weight="fill" />
An mir anprobieren
</span>
<span class="text-xs font-normal opacity-75">{estimatedCredits} Credits</span>
{/if}
</button>

View file

@ -164,16 +164,21 @@
type="button"
onclick={handleClick}
disabled={running || !canTryOn}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-full flex-col items-center justify-center gap-0.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if running}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Rendere…
<span class="flex items-center gap-2">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></span>
Rendere…
</span>
{:else}
<Sparkle size={16} weight="fill" />
Anprobieren · {estimatedCredits} Credits
<span class="flex items-center gap-2">
<Sparkle size={16} weight="fill" />
Anprobieren
</span>
<span class="text-xs font-normal opacity-75">{estimatedCredits} Credits</span>
{/if}
</button>

View file

@ -0,0 +1,73 @@
/**
* Turn an upload filename into a presentable default garment name.
*
* Filenames from e-commerce sources and phone cameras come in as
* URL-safe slugs with SKU-numbers, duplicate segments, and hyphens
* e.g. `17390-gestreiftes-herren-t-shirt-aus-baumwolle-17390-2-w.png`.
* A raw strip-extension leaves the user staring at that string as the
* display name and having to manually clean it up. This helper does
* a best-effort pretty-print so the default label is usable as-is.
*
* Rules (order matters):
* 1. Strip the last extension.
* 2. Replace underscores + hyphens with spaces.
* 3. Collapse runs of whitespace.
* 4. Drop "pure-number" tokens that look like SKU / size codes
* ( 4 digits AND longer than any letter-only neighbour matches
* `17390`, `2-w` stays because it's not pure digits). The short
* alpha-numerics like `4xl` / `w38` are kept; stock codes are not.
* 5. Title-case each remaining word. "T-Shirt" style hyphenated terms
* are rebuilt by re-hyphenating two-letter-max fragments so
* `t-shirt` becomes `T-Shirt` and `v-neck` becomes `V-Neck`.
* 6. Trim trailing punctuation + clamp to 80 characters on a word
* boundary so wild inputs don't blow up the UI.
*
* Returns a non-empty string falls back to the trimmed, extension-
* less original when normalisation would otherwise yield "".
*/
export function prettifyUploadName(filename: string): string {
const extIdx = filename.lastIndexOf('.');
const withoutExt = extIdx > 0 ? filename.slice(0, extIdx) : filename;
const raw = withoutExt.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!raw) return filename;
// Token filter: drop pure-digit tokens of length ≥ 4 (SKU-shaped).
const tokens = raw.split(' ').filter((t) => !(t.length >= 4 && /^\d+$/.test(t)));
const titled = tokens
.map((t) => {
// "t-shirt" would have been split on hyphens earlier, but if a
// caller pre-tokenised with hyphens we stitch them back here.
if (t.includes('-')) {
return t
.split('-')
.map((seg) => capitalise(seg))
.join('-');
}
return capitalise(t);
})
.join(' ')
.replace(/[\s.,;:\-]+$/, '');
const clamped = clampAtWordBoundary(titled, 80);
return clamped || withoutExt;
}
function capitalise(word: string): string {
if (word.length === 0) return word;
// Keep short tokens that look like codes (`4xl`, `w38`) uppercase
// for readability. Anything ≤ 2 chars or mixed-digit-letter stays
// uppercased so `T-Shirt` works and `w38` reads as `W38`.
if (word.length <= 2 || /[0-9]/.test(word)) {
return word.toUpperCase();
}
return word[0].toUpperCase() + word.slice(1).toLowerCase();
}
function clampAtWordBoundary(s: string, max: number): string {
if (s.length <= max) return s;
const cut = s.slice(0, max);
const lastSpace = cut.lastIndexOf(' ');
return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[\s.,;:\-]+$/, '');
}

View file

@ -6,7 +6,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, CheckCircle, PencilSimple, Archive, Trash } from '@mana/shared-icons';
import { CheckCircle, PencilSimple, Archive, Trash } from '@mana/shared-icons';
import { useGarment, useGarmentSoloTryOns, useOutfitsContainingGarment } from '../queries';
import { wardrobeGarmentsStore } from '../stores/garments.svelte';
import { garmentPhotoUrl } from '../api/media-url';
@ -81,18 +81,10 @@
}
</script>
<!-- The ModuleShell wrapping this view already renders both the
back-arrow and the "Kleiderschrank" title in its header. An inner
breadcrumb would double them up — drop it. -->
<div class="mx-auto max-w-3xl space-y-5 p-4 sm:p-6">
<nav class="flex items-center gap-2 text-sm">
<a
href="/wardrobe"
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
aria-label="Zurück zum Kleiderschrank"
>
<ArrowLeft size={16} />
</a>
<span class="text-muted-foreground">Kleiderschrank</span>
</nav>
{#if !garment}
{#if garment$.loading}
<p class="text-sm text-muted-foreground">Lädt…</p>
@ -248,7 +240,6 @@
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Anproben · {soloTryOns.length}
</h2>
<span class="text-xs text-muted-foreground">Einzelstück auf dir gerendert</span>
</header>
<div class="flex gap-3 overflow-x-auto pb-1">
{#each soloTryOns as image (image.id)}
@ -282,7 +273,6 @@
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
In Outfits · {outfits.length}
</h2>
<span class="text-xs text-muted-foreground">Komposition öffnen</span>
</header>
<div class="flex gap-3 overflow-x-auto pb-1">
{#each outfits as outfit (outfit.id)}

View file

@ -24,6 +24,7 @@
import { CATEGORY_LABELS, CATEGORY_LABELS_SINGULAR } from '../constants';
import CategoryTabs from '../components/CategoryTabs.svelte';
import GarmentCard from '../components/GarmentCard.svelte';
import { prettifyUploadName } from '../utils/name';
import { getActiveSpace } from '$lib/data/scope';
import type { GarmentCategory } from '../types';
@ -48,11 +49,6 @@
let uploading = $state(false);
let uploadError = $state<string | null>(null);
function stripExt(filename: string): string {
const i = filename.lastIndexOf('.');
return i > 0 ? filename.slice(0, i) : filename;
}
async function ingestFiles(files: File[]) {
// Pre-select kind from the active tab. Drops on "Alle" land as
// 'other' — less specific, user edits on the detail page. Drops
@ -67,7 +63,12 @@
await readImageDimensions(file);
const uploaded = await uploadGarmentPhoto(file);
await wardrobeGarmentsStore.createGarment({
name: stripExt(file.name),
// prettifyUploadName turns e-commerce slugs like
// `17390-gestreiftes-herren-t-shirt-aus-baumwolle-17390-2-w`
// into `Gestreiftes Herren-T-Shirt Aus Baumwolle 2-W` so the
// garment row lands with a presentable default. User still
// edits on the detail page for anything nuanced.
name: prettifyUploadName(file.name),
category: defaultCategory,
mediaIds: [uploaded.mediaId],
});

View file

@ -552,7 +552,11 @@ async function resolveComicStories(props: ModuleEmbedProps): Promise<EmbedItem[]
const coverImageIds = decrypted
.map((s) => s.panelImageIds?.[0])
.filter((id): id is string => Boolean(id));
const coverImages = await db.table<LocalImage>('images').where('id').anyOf(coverImageIds).toArray();
const coverImages = await db
.table<LocalImage>('images')
.where('id')
.anyOf(coverImageIds)
.toArray();
const coverById = new Map<string, LocalImage>();
for (const img of coverImages) coverById.set(img.id, img);