From 1d58f8ce76795ce5b165cfc9b8239a1dc94a6320 Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:45:13 +0200 Subject: [PATCH 1/2] feat: share an outfit as a picture Co-Authored-By: Claude Opus 5.5 --- frontend/components/outfit-preview-dialog.tsx | 6 +- frontend/components/share-outfit-button.tsx | 55 ++++++++ frontend/lib/i18n/locales.ts | 1 + frontend/lib/outfit-collage.ts | 121 ++++++++++++++++++ frontend/messages/de/share.json | 7 + frontend/messages/en/share.json | 7 + frontend/messages/fr/share.json | 7 + frontend/messages/it/share.json | 7 + frontend/messages/ja/share.json | 7 + frontend/messages/ko/share.json | 7 + frontend/messages/zh-CN/share.json | 7 + frontend/messages/zh-TW/share.json | 7 + 12 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 frontend/components/share-outfit-button.tsx create mode 100644 frontend/lib/outfit-collage.ts create mode 100644 frontend/messages/de/share.json create mode 100644 frontend/messages/en/share.json create mode 100644 frontend/messages/fr/share.json create mode 100644 frontend/messages/it/share.json create mode 100644 frontend/messages/ja/share.json create mode 100644 frontend/messages/ko/share.json create mode 100644 frontend/messages/zh-CN/share.json create mode 100644 frontend/messages/zh-TW/share.json diff --git a/frontend/components/outfit-preview-dialog.tsx b/frontend/components/outfit-preview-dialog.tsx index c99765ef0..29547baee 100644 --- a/frontend/components/outfit-preview-dialog.tsx +++ b/frontend/components/outfit-preview-dialog.tsx @@ -15,6 +15,7 @@ import { useRotateImage } from '@/lib/hooks/use-items'; import { FamilyRatingForm, FamilyRatingsDisplay } from '@/components/family-ratings'; import { toast } from 'sonner'; import Image from 'next/image'; +import { ShareOutfitButton } from '@/components/share-outfit-button'; import { useLocale, useTranslations } from 'next-intl'; import { formatShortDate } from '@/lib/utils'; import { useOccasionLabel, useSubtypeLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants'; @@ -343,8 +344,9 @@ export function OutfitPreviewDialog({ outfit, open, onClose, isOwner = true }: O {/* Close button at bottom - always visible */} -
-
diff --git a/frontend/components/share-outfit-button.tsx b/frontend/components/share-outfit-button.tsx new file mode 100644 index 000000000..c37631151 --- /dev/null +++ b/frontend/components/share-outfit-button.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { toast } from 'sonner'; +import { Loader2, Share2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { renderOutfitCollage, shareOrDownload } from '@/lib/outfit-collage'; +import { useOccasionLabel } from '@/lib/hooks/use-translated-constants'; +import { formatShortDate } from '@/lib/utils'; +import type { Outfit } from '@/lib/types'; + +type ShareableOutfit = Pick & { name?: string | null }; + +export function ShareOutfitButton({ outfit, className }: { outfit: ShareableOutfit; className?: string }) { + const t = useTranslations('share'); + const locale = useLocale(); + const occasionLabel = useOccasionLabel(); + const [busy, setBusy] = useState(false); + + const share = async () => { + setBusy(true); + try { + const urls = [...outfit.items] + .sort((a, b) => a.position - b.position) + .map((item) => item.image_url || item.thumbnail_url) + .filter((url): url is string => Boolean(url)); + const title = outfit.name || t('title', { occasion: occasionLabel(outfit.occasion) }); + const blob = await renderOutfitCollage(urls, { + title, + subtitle: outfit.scheduled_for ? formatShortDate(outfit.scheduled_for, locale) : undefined, + footer: t('footer'), + }); + const result = await shareOrDownload(blob, `outfit-${outfit.id.slice(0, 8)}.png`, title); + if (result === 'downloaded') toast.success(t('downloaded')); + } catch { + toast.error(t('error')); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/frontend/lib/i18n/locales.ts b/frontend/lib/i18n/locales.ts index 379ca5667..a92f5080d 100644 --- a/frontend/lib/i18n/locales.ts +++ b/frontend/lib/i18n/locales.ts @@ -40,6 +40,7 @@ export const NAMESPACES = [ 'outfits', 'constants', 'errors', + 'share', ] as const; export type Namespace = (typeof NAMESPACES)[number]; diff --git a/frontend/lib/outfit-collage.ts b/frontend/lib/outfit-collage.ts new file mode 100644 index 000000000..a9a5db5c7 --- /dev/null +++ b/frontend/lib/outfit-collage.ts @@ -0,0 +1,121 @@ +// An outfit as one portrait image (1080x1350, the size chat apps and stories crop least), +// drawn in the browser from the item photos so nothing leaves the device until it is shared. + +const WIDTH = 1080; +const HEIGHT = 1350; +const MARGIN = 48; +const GAP = 24; +const HEADER = 150; +const FOOTER = 70; + +export interface CollageText { + title: string; + subtitle?: string; + footer: string; +} + +function loadImage(src: string): Promise { + return new Promise((resolve) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = src; + }); +} + +function columnsFor(count: number): number { + if (count <= 1) return 1; + if (count <= 4) return 2; + return 3; +} + +function roundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +export async function renderOutfitCollage(imageUrls: string[], text: CollageText): Promise { + const canvas = document.createElement('canvas'); + canvas.width = WIDTH; + canvas.height = HEIGHT; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas is not available'); + + ctx.fillStyle = '#f6f5f2'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + ctx.fillStyle = '#111111'; + ctx.font = '600 56px system-ui, -apple-system, Segoe UI, sans-serif'; + ctx.fillText(text.title, MARGIN, MARGIN + 52, WIDTH - 2 * MARGIN); + if (text.subtitle) { + ctx.fillStyle = '#6b6b6b'; + ctx.font = '400 34px system-ui, -apple-system, Segoe UI, sans-serif'; + ctx.fillText(text.subtitle, MARGIN, MARGIN + 100, WIDTH - 2 * MARGIN); + } + + const images = (await Promise.all(imageUrls.slice(0, 9).map(loadImage))).filter( + (image): image is HTMLImageElement => image !== null + ); + const columns = columnsFor(images.length); + const rows = Math.max(1, Math.ceil(images.length / columns)); + const areaTop = MARGIN + HEADER; + const areaHeight = HEIGHT - areaTop - FOOTER - MARGIN; + const cellW = (WIDTH - 2 * MARGIN - (columns - 1) * GAP) / columns; + const cellH = (areaHeight - (rows - 1) * GAP) / rows; + + images.forEach((image, index) => { + const x = MARGIN + (index % columns) * (cellW + GAP); + const y = areaTop + Math.floor(index / columns) * (cellH + GAP); + ctx.save(); + roundedRect(ctx, x, y, cellW, cellH, 28); + ctx.fillStyle = '#ffffff'; + ctx.fill(); + ctx.clip(); + const scale = Math.min((cellW - 32) / image.width, (cellH - 32) / image.height); + const w = image.width * scale; + const h = image.height * scale; + ctx.drawImage(image, x + (cellW - w) / 2, y + (cellH - h) / 2, w, h); + ctx.restore(); + }); + + ctx.fillStyle = '#9a9a9a'; + ctx.font = '500 28px system-ui, -apple-system, Segoe UI, sans-serif'; + ctx.fillText(text.footer, MARGIN, HEIGHT - MARGIN, WIDTH - 2 * MARGIN); + + return new Promise((resolve, reject) => + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Export failed'))), 'image/png') + ); +} + +/** Opens the system share sheet with the picture, or downloads it where sharing files is not + * supported. Returns how it was delivered. */ +export async function shareOrDownload( + blob: Blob, + filename: string, + title: string +): Promise<'shared' | 'downloaded' | 'cancelled'> { + const file = new File([blob], filename, { type: 'image/png' }); + if (typeof navigator !== 'undefined' && navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file], title }); + return 'shared'; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled'; + } + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); + return 'downloaded'; +} diff --git a/frontend/messages/de/share.json b/frontend/messages/de/share.json new file mode 100644 index 000000000..1016b9d60 --- /dev/null +++ b/frontend/messages/de/share.json @@ -0,0 +1,7 @@ +{ + "button": "Teilen", + "downloaded": "Bild gespeichert", + "error": "Das Bild konnte nicht erstellt werden.", + "footer": "Erstellt mit Wardrowbe", + "title": "Outfit: {occasion}" +} diff --git a/frontend/messages/en/share.json b/frontend/messages/en/share.json new file mode 100644 index 000000000..90ffd8bbb --- /dev/null +++ b/frontend/messages/en/share.json @@ -0,0 +1,7 @@ +{ + "button": "Share", + "downloaded": "Picture saved", + "error": "Could not create the picture.", + "footer": "Made with Wardrowbe", + "title": "{occasion} outfit" +} diff --git a/frontend/messages/fr/share.json b/frontend/messages/fr/share.json new file mode 100644 index 000000000..7fa186dc7 --- /dev/null +++ b/frontend/messages/fr/share.json @@ -0,0 +1,7 @@ +{ + "button": "Partager", + "downloaded": "Image enregistrée", + "error": "Impossible de créer l'image.", + "footer": "Créé avec Wardrowbe", + "title": "Tenue {occasion}" +} diff --git a/frontend/messages/it/share.json b/frontend/messages/it/share.json new file mode 100644 index 000000000..d116d4ea2 --- /dev/null +++ b/frontend/messages/it/share.json @@ -0,0 +1,7 @@ +{ + "button": "Condividi", + "downloaded": "Immagine salvata", + "error": "Impossibile creare l'immagine.", + "footer": "Creato con Wardrowbe", + "title": "Outfit {occasion}" +} diff --git a/frontend/messages/ja/share.json b/frontend/messages/ja/share.json new file mode 100644 index 000000000..af7b34192 --- /dev/null +++ b/frontend/messages/ja/share.json @@ -0,0 +1,7 @@ +{ + "button": "共有", + "downloaded": "画像を保存しました", + "error": "画像を作成できませんでした。", + "footer": "Wardrowbeで作成", + "title": "{occasion}のコーデ" +} diff --git a/frontend/messages/ko/share.json b/frontend/messages/ko/share.json new file mode 100644 index 000000000..4cf23a655 --- /dev/null +++ b/frontend/messages/ko/share.json @@ -0,0 +1,7 @@ +{ + "button": "공유", + "downloaded": "이미지를 저장했어요", + "error": "이미지를 만들지 못했어요.", + "footer": "Wardrowbe로 만들었어요", + "title": "{occasion} 코디" +} diff --git a/frontend/messages/zh-CN/share.json b/frontend/messages/zh-CN/share.json new file mode 100644 index 000000000..682b84ce7 --- /dev/null +++ b/frontend/messages/zh-CN/share.json @@ -0,0 +1,7 @@ +{ + "button": "分享", + "downloaded": "图片已保存", + "error": "无法生成图片。", + "footer": "由 Wardrowbe 生成", + "title": "{occasion}穿搭" +} diff --git a/frontend/messages/zh-TW/share.json b/frontend/messages/zh-TW/share.json new file mode 100644 index 000000000..519170354 --- /dev/null +++ b/frontend/messages/zh-TW/share.json @@ -0,0 +1,7 @@ +{ + "button": "分享", + "downloaded": "圖片已儲存", + "error": "無法產生圖片。", + "footer": "由 Wardrowbe 產生", + "title": "{occasion}穿搭" +} From 9093f319ace38e348c9ea9869dd470dd23352a96 Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:19:28 +0200 Subject: [PATCH 2/2] fix(share): no picture with missing photos - A photo that fails to load stops the export instead of leaving a gap or a blank picture; the button first fetches the outfit again for fresh photo links and retries once, then explains what went wrong. - Outfits with more than nine pieces show eight photos and a +N tile. - The button is off when no piece has a photo. Co-Authored-By: Claude Opus 5.5 --- frontend/components/share-outfit-button.tsx | 33 +++++++++++----- frontend/lib/outfit-collage.ts | 42 ++++++++++++++++++--- frontend/messages/de/share.json | 1 + frontend/messages/en/share.json | 1 + frontend/messages/fr/share.json | 1 + frontend/messages/it/share.json | 1 + frontend/messages/ja/share.json | 1 + frontend/messages/ko/share.json | 1 + frontend/messages/zh-CN/share.json | 1 + frontend/messages/zh-TW/share.json | 1 + 10 files changed, 68 insertions(+), 15 deletions(-) diff --git a/frontend/components/share-outfit-button.tsx b/frontend/components/share-outfit-button.tsx index c37631151..5a2f59965 100644 --- a/frontend/components/share-outfit-button.tsx +++ b/frontend/components/share-outfit-button.tsx @@ -5,7 +5,8 @@ import { useLocale, useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { Loader2, Share2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { renderOutfitCollage, shareOrDownload } from '@/lib/outfit-collage'; +import { api } from '@/lib/api'; +import { MissingPhotosError, renderOutfitCollage, shareOrDownload } from '@/lib/outfit-collage'; import { useOccasionLabel } from '@/lib/hooks/use-translated-constants'; import { formatShortDate } from '@/lib/utils'; import type { Outfit } from '@/lib/types'; @@ -18,23 +19,35 @@ export function ShareOutfitButton({ outfit, className }: { outfit: ShareableOutf const occasionLabel = useOccasionLabel(); const [busy, setBusy] = useState(false); + const photoUrls = (items: ShareableOutfit['items']) => + [...items] + .sort((a, b) => a.position - b.position) + .map((item) => item.image_url || item.thumbnail_url) + .filter((url): url is string => Boolean(url)); + const hasPhotos = photoUrls(outfit.items).length > 0; + const share = async () => { setBusy(true); try { - const urls = [...outfit.items] - .sort((a, b) => a.position - b.position) - .map((item) => item.image_url || item.thumbnail_url) - .filter((url): url is string => Boolean(url)); const title = outfit.name || t('title', { occasion: occasionLabel(outfit.occasion) }); - const blob = await renderOutfitCollage(urls, { + const text = { title, subtitle: outfit.scheduled_for ? formatShortDate(outfit.scheduled_for, locale) : undefined, footer: t('footer'), - }); + }; + let blob: Blob; + try { + blob = await renderOutfitCollage(photoUrls(outfit.items), text); + } catch (error) { + if (!(error instanceof MissingPhotosError)) throw error; + // Photo links expire: ask for the outfit again for fresh ones, and try once more. + const fresh = await api.get(`/outfits/${outfit.id}`); + blob = await renderOutfitCollage(photoUrls(fresh.items), text); + } const result = await shareOrDownload(blob, `outfit-${outfit.id.slice(0, 8)}.png`, title); if (result === 'downloaded') toast.success(t('downloaded')); - } catch { - toast.error(t('error')); + } catch (error) { + toast.error(error instanceof MissingPhotosError ? t('missingPhotos') : t('error')); } finally { setBusy(false); } @@ -45,7 +58,7 @@ export function ShareOutfitButton({ outfit, className }: { outfit: ShareableOutf type="button" variant="outline" onClick={share} - disabled={busy || outfit.items.length === 0} + disabled={busy || !hasPhotos} className={className} > {busy ? : } diff --git a/frontend/lib/outfit-collage.ts b/frontend/lib/outfit-collage.ts index a9a5db5c7..97e144a15 100644 --- a/frontend/lib/outfit-collage.ts +++ b/frontend/lib/outfit-collage.ts @@ -7,6 +7,17 @@ const MARGIN = 48; const GAP = 24; const HEADER = 150; const FOOTER = 70; +// Nine tiles at most; a longer outfit shows eight photos and a "+N" tile for the rest. +const MAX_TILES = 9; + +/** Some photos could not be loaded (often an expired link): the picture would be missing + * pieces, so nothing is exported. */ +export class MissingPhotosError extends Error { + constructor(readonly missing: number) { + super(`${missing} photo(s) could not be loaded`); + this.name = 'MissingPhotosError'; + } +} export interface CollageText { title: string; @@ -59,11 +70,16 @@ export async function renderOutfitCollage(imageUrls: string[], text: CollageText ctx.fillText(text.subtitle, MARGIN, MARGIN + 100, WIDTH - 2 * MARGIN); } - const images = (await Promise.all(imageUrls.slice(0, 9).map(loadImage))).filter( - (image): image is HTMLImageElement => image !== null - ); - const columns = columnsFor(images.length); - const rows = Math.max(1, Math.ceil(images.length / columns)); + if (imageUrls.length === 0) throw new MissingPhotosError(0); + const overflow = imageUrls.length > MAX_TILES ? imageUrls.length - (MAX_TILES - 1) : 0; + const shown = overflow ? imageUrls.slice(0, MAX_TILES - 1) : imageUrls; + const loaded = await Promise.all(shown.map(loadImage)); + const missing = loaded.filter((image) => image === null).length; + if (missing) throw new MissingPhotosError(missing); + const images = loaded as HTMLImageElement[]; + const tiles = images.length + (overflow ? 1 : 0); + const columns = columnsFor(tiles); + const rows = Math.max(1, Math.ceil(tiles / columns)); const areaTop = MARGIN + HEADER; const areaHeight = HEIGHT - areaTop - FOOTER - MARGIN; const cellW = (WIDTH - 2 * MARGIN - (columns - 1) * GAP) / columns; @@ -84,6 +100,22 @@ export async function renderOutfitCollage(imageUrls: string[], text: CollageText ctx.restore(); }); + if (overflow) { + const index = images.length; + const x = MARGIN + (index % columns) * (cellW + GAP); + const y = areaTop + Math.floor(index / columns) * (cellH + GAP); + roundedRect(ctx, x, y, cellW, cellH, 28); + ctx.fillStyle = '#e7e4dd'; + ctx.fill(); + ctx.fillStyle = '#444444'; + ctx.font = '600 72px system-ui, -apple-system, Segoe UI, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`+${overflow}`, x + cellW / 2, y + cellH / 2); + ctx.textAlign = 'start'; + ctx.textBaseline = 'alphabetic'; + } + ctx.fillStyle = '#9a9a9a'; ctx.font = '500 28px system-ui, -apple-system, Segoe UI, sans-serif'; ctx.fillText(text.footer, MARGIN, HEIGHT - MARGIN, WIDTH - 2 * MARGIN); diff --git a/frontend/messages/de/share.json b/frontend/messages/de/share.json index 1016b9d60..46afbcec8 100644 --- a/frontend/messages/de/share.json +++ b/frontend/messages/de/share.json @@ -3,5 +3,6 @@ "downloaded": "Bild gespeichert", "error": "Das Bild konnte nicht erstellt werden.", "footer": "Erstellt mit Wardrowbe", + "missingPhotos": "Einige Fotos konnten nicht geladen werden, daher wurde kein Bild erstellt.", "title": "Outfit: {occasion}" } diff --git a/frontend/messages/en/share.json b/frontend/messages/en/share.json index 90ffd8bbb..3cc93a9cf 100644 --- a/frontend/messages/en/share.json +++ b/frontend/messages/en/share.json @@ -3,5 +3,6 @@ "downloaded": "Picture saved", "error": "Could not create the picture.", "footer": "Made with Wardrowbe", + "missingPhotos": "Some photos could not be loaded, so the picture was not made.", "title": "{occasion} outfit" } diff --git a/frontend/messages/fr/share.json b/frontend/messages/fr/share.json index 7fa186dc7..74d9c66c9 100644 --- a/frontend/messages/fr/share.json +++ b/frontend/messages/fr/share.json @@ -3,5 +3,6 @@ "downloaded": "Image enregistrée", "error": "Impossible de créer l'image.", "footer": "Créé avec Wardrowbe", + "missingPhotos": "Certaines photos n'ont pas pu être chargées, l'image n'a donc pas été créée.", "title": "Tenue {occasion}" } diff --git a/frontend/messages/it/share.json b/frontend/messages/it/share.json index d116d4ea2..687a134f6 100644 --- a/frontend/messages/it/share.json +++ b/frontend/messages/it/share.json @@ -3,5 +3,6 @@ "downloaded": "Immagine salvata", "error": "Impossibile creare l'immagine.", "footer": "Creato con Wardrowbe", + "missingPhotos": "Alcune foto non si sono caricate, quindi l'immagine non è stata creata.", "title": "Outfit {occasion}" } diff --git a/frontend/messages/ja/share.json b/frontend/messages/ja/share.json index af7b34192..60008b77a 100644 --- a/frontend/messages/ja/share.json +++ b/frontend/messages/ja/share.json @@ -3,5 +3,6 @@ "downloaded": "画像を保存しました", "error": "画像を作成できませんでした。", "footer": "Wardrowbeで作成", + "missingPhotos": "読み込めない写真があったため、画像を作成しませんでした。", "title": "{occasion}のコーデ" } diff --git a/frontend/messages/ko/share.json b/frontend/messages/ko/share.json index 4cf23a655..36ad3786d 100644 --- a/frontend/messages/ko/share.json +++ b/frontend/messages/ko/share.json @@ -3,5 +3,6 @@ "downloaded": "이미지를 저장했어요", "error": "이미지를 만들지 못했어요.", "footer": "Wardrowbe로 만들었어요", + "missingPhotos": "일부 사진을 불러오지 못해 이미지를 만들지 않았어요.", "title": "{occasion} 코디" } diff --git a/frontend/messages/zh-CN/share.json b/frontend/messages/zh-CN/share.json index 682b84ce7..ff35e973a 100644 --- a/frontend/messages/zh-CN/share.json +++ b/frontend/messages/zh-CN/share.json @@ -3,5 +3,6 @@ "downloaded": "图片已保存", "error": "无法生成图片。", "footer": "由 Wardrowbe 生成", + "missingPhotos": "部分照片无法加载,因此未生成图片。", "title": "{occasion}穿搭" } diff --git a/frontend/messages/zh-TW/share.json b/frontend/messages/zh-TW/share.json index 519170354..1136014c9 100644 --- a/frontend/messages/zh-TW/share.json +++ b/frontend/messages/zh-TW/share.json @@ -3,5 +3,6 @@ "downloaded": "圖片已儲存", "error": "無法產生圖片。", "footer": "由 Wardrowbe 產生", + "missingPhotos": "部分照片無法載入,因此未產生圖片。", "title": "{occasion}穿搭" }