Repository navigation
feat: share an outfit as a picture #281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TheBlackSide243
wants to merge
2
commits into
Anyesh:main
Choose a base branch
from
TheBlackSide243:pr/share-outfit
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+290
−2
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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'; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "button": "共有", | ||
| "downloaded": "画像を保存しました", | ||
| "error": "画像を作成できませんでした。", | ||
| "footer": "Wardrowbeで作成", | ||
| "missingPhotos": "読み込めない写真があったため、画像を作成しませんでした。", | ||
| "title": "{occasion}のコーデ" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "button": "공유", | ||
| "downloaded": "이미지를 저장했어요", | ||
| "error": "이미지를 만들지 못했어요.", | ||
| "footer": "Wardrowbe로 만들었어요", | ||
| "missingPhotos": "일부 사진을 불러오지 못해 이미지를 만들지 않았어요.", | ||
| "title": "{occasion} 코디" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "button": "分享", | ||
| "downloaded": "图片已保存", | ||
| "error": "无法生成图片。", | ||
| "footer": "由 Wardrowbe 生成", | ||
| "missingPhotos": "部分照片无法加载,因此未生成图片。", | ||
| "title": "{occasion}穿搭" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "button": "分享", | ||
| "downloaded": "圖片已儲存", | ||
| "error": "無法產生圖片。", | ||
| "footer": "由 Wardrowbe 產生", | ||
| "missingPhotos": "部分照片無法載入,因此未產生圖片。", | ||
| "title": "{occasion}穿搭" | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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,
loadImageresolves failed requests asnull, andrenderOutfitCollagefilters 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