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..5a2f59965 --- /dev/null +++ b/frontend/components/share-outfit-button.tsx @@ -0,0 +1,68 @@ +'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 { 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'; + +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 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 title = outfit.name || t('title', { occasion: occasionLabel(outfit.occasion) }); + 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 (error) { + toast.error(error instanceof MissingPhotosError ? t('missingPhotos') : 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..97e144a15 --- /dev/null +++ b/frontend/lib/outfit-collage.ts @@ -0,0 +1,153 @@ +// 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; +// 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; + 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); + } + + 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; + 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(); + }); + + 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); + + 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..46afbcec8 --- /dev/null +++ b/frontend/messages/de/share.json @@ -0,0 +1,8 @@ +{ + "button": "Teilen", + "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 new file mode 100644 index 000000000..3cc93a9cf --- /dev/null +++ b/frontend/messages/en/share.json @@ -0,0 +1,8 @@ +{ + "button": "Share", + "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 new file mode 100644 index 000000000..74d9c66c9 --- /dev/null +++ b/frontend/messages/fr/share.json @@ -0,0 +1,8 @@ +{ + "button": "Partager", + "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 new file mode 100644 index 000000000..687a134f6 --- /dev/null +++ b/frontend/messages/it/share.json @@ -0,0 +1,8 @@ +{ + "button": "Condividi", + "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 new file mode 100644 index 000000000..60008b77a --- /dev/null +++ b/frontend/messages/ja/share.json @@ -0,0 +1,8 @@ +{ + "button": "共有", + "downloaded": "画像を保存しました", + "error": "画像を作成できませんでした。", + "footer": "Wardrowbeで作成", + "missingPhotos": "読み込めない写真があったため、画像を作成しませんでした。", + "title": "{occasion}のコーデ" +} diff --git a/frontend/messages/ko/share.json b/frontend/messages/ko/share.json new file mode 100644 index 000000000..36ad3786d --- /dev/null +++ b/frontend/messages/ko/share.json @@ -0,0 +1,8 @@ +{ + "button": "공유", + "downloaded": "이미지를 저장했어요", + "error": "이미지를 만들지 못했어요.", + "footer": "Wardrowbe로 만들었어요", + "missingPhotos": "일부 사진을 불러오지 못해 이미지를 만들지 않았어요.", + "title": "{occasion} 코디" +} diff --git a/frontend/messages/zh-CN/share.json b/frontend/messages/zh-CN/share.json new file mode 100644 index 000000000..ff35e973a --- /dev/null +++ b/frontend/messages/zh-CN/share.json @@ -0,0 +1,8 @@ +{ + "button": "分享", + "downloaded": "图片已保存", + "error": "无法生成图片。", + "footer": "由 Wardrowbe 生成", + "missingPhotos": "部分照片无法加载,因此未生成图片。", + "title": "{occasion}穿搭" +} diff --git a/frontend/messages/zh-TW/share.json b/frontend/messages/zh-TW/share.json new file mode 100644 index 000000000..1136014c9 --- /dev/null +++ b/frontend/messages/zh-TW/share.json @@ -0,0 +1,8 @@ +{ + "button": "分享", + "downloaded": "圖片已儲存", + "error": "無法產生圖片。", + "footer": "由 Wardrowbe 產生", + "missingPhotos": "部分照片無法載入,因此未產生圖片。", + "title": "{occasion}穿搭" +}