Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions frontend/components/outfit-preview-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -343,8 +344,9 @@ export function OutfitPreviewDialog({ outfit, open, onClose, isOwner = true }: O
</div>

{/* Close button at bottom - always visible */}
<div className="border-t p-3 flex-shrink-0">
<Button variant="outline" className="w-full" onClick={onClose}>
<div className="border-t p-3 flex-shrink-0 flex gap-2">
<ShareOutfitButton outfit={outfit} className="flex-1" />
<Button variant="outline" className="flex-1" onClick={onClose}>
{tc('close')}
</Button>
</div>
Expand Down
68 changes: 68 additions & 0 deletions frontend/components/share-outfit-button.tsx
Original file line number Diff line number Diff line change
@@ -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<Outfit, 'id' | 'items' | 'occasion' | 'scheduled_for'> & { 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<Outfit>(`/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 (
<Button
type="button"
variant="outline"
onClick={share}
disabled={busy || !hasPhotos}
className={className}
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Share2 className="mr-2 h-4 w-4" />}
{t('button')}
</Button>
);
}
1 change: 1 addition & 0 deletions frontend/lib/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const NAMESPACES = [
'outfits',
'constants',
'errors',
'share',
] as const;

export type Namespace = (typeof NAMESPACES)[number];
Expand Down
153 changes: 153 additions & 0 deletions frontend/lib/outfit-collage.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement | null> {
return new Promise((resolve) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => resolve(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Incomplete collages report success

When one or more image URLs fail to load, loadImage resolves failed requests as null, and renderOutfitCollage filters those results before exporting. The share flow therefore reports success with missing outfit photos, or a blank image.

Reject the render or report failed images instead of filtering them out and exporting the collage.

Also at frontend/lib/outfit-collage.ts:62-64.

Prompt for AI agents
In `frontend/lib/outfit-collage.ts` at line 22:

**Incomplete collages report success**

When one or more image URLs fail to load, `loadImage` resolves failed requests as `null`, and `renderOutfitCollage` filters those results before exporting. The share flow therefore reports success with missing outfit photos, or a blank image.

Reject the render or report failed images instead of filtering them out and exporting the collage.

Also at `frontend/lib/outfit-collage.ts:62-64`.

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<Blob> {
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';
}
8 changes: 8 additions & 0 deletions frontend/messages/de/share.json
Original file line number Diff line number Diff line change
@@ -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}"
}
8 changes: 8 additions & 0 deletions frontend/messages/en/share.json
Original file line number Diff line number Diff line change
@@ -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"
}
8 changes: 8 additions & 0 deletions frontend/messages/fr/share.json
Original file line number Diff line number Diff line change
@@ -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}"
}
8 changes: 8 additions & 0 deletions frontend/messages/it/share.json
Original file line number Diff line number Diff line change
@@ -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}"
}
8 changes: 8 additions & 0 deletions frontend/messages/ja/share.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"button": "共有",
"downloaded": "画像を保存しました",
"error": "画像を作成できませんでした。",
"footer": "Wardrowbeで作成",
"missingPhotos": "読み込めない写真があったため、画像を作成しませんでした。",
"title": "{occasion}のコーデ"
}
8 changes: 8 additions & 0 deletions frontend/messages/ko/share.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"button": "공유",
"downloaded": "이미지를 저장했어요",
"error": "이미지를 만들지 못했어요.",
"footer": "Wardrowbe로 만들었어요",
"missingPhotos": "일부 사진을 불러오지 못해 이미지를 만들지 않았어요.",
"title": "{occasion} 코디"
}
8 changes: 8 additions & 0 deletions frontend/messages/zh-CN/share.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"button": "分享",
"downloaded": "图片已保存",
"error": "无法生成图片。",
"footer": "由 Wardrowbe 生成",
"missingPhotos": "部分照片无法加载,因此未生成图片。",
"title": "{occasion}穿搭"
}
8 changes: 8 additions & 0 deletions frontend/messages/zh-TW/share.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"button": "分享",
"downloaded": "圖片已儲存",
"error": "無法產生圖片。",
"footer": "由 Wardrowbe 產生",
"missingPhotos": "部分照片無法載入,因此未產生圖片。",
"title": "{occasion}穿搭"
}
Loading