mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
87b567eec9
commit
05b2209232
7 changed files with 116 additions and 45 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
73
apps/mana/apps/web/src/lib/modules/wardrobe/utils/name.ts
Normal file
73
apps/mana/apps/web/src/lib/modules/wardrobe/utils/name.ts
Normal 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.,;:\-]+$/, '');
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue