import { supabase } from './supabase'; const BUCKET = 'profile-banners'; // 3:1 hero crop. 1500x500 hits the sweet spot between crisp on a wide // settings preview and a payload that stays well under the 8 MB pre-encode // budget we accept from the user (post-WebP that's typically ~150–300 KB). const TARGET_WIDTH = 1500; const TARGET_HEIGHT = 500; const QUALITY = 0.82; const MAX_INPUT_BYTES = 8 * 1024 * 1024; // Resizes the source image to a centred 3:1 crop at TARGET_WIDTH x TARGET_HEIGHT // and re-encodes as WebP (JPEG fallback if WebP encode unsupported). async function resizeToBanner(file: File): Promise { const url = URL.createObjectURL(file); try { const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = () => reject(new Error('image load failed')); i.src = url; }); const srcRatio = img.naturalWidth / img.naturalHeight; const targetRatio = TARGET_WIDTH / TARGET_HEIGHT; let sx = 0; let sy = 0; let sw = img.naturalWidth; let sh = img.naturalHeight; if (srcRatio > targetRatio) { // Source is wider than 3:1 — crop horizontally, keep full height. sw = Math.round(img.naturalHeight * targetRatio); sx = Math.round((img.naturalWidth - sw) / 2); } else if (srcRatio < targetRatio) { // Source is taller than 3:1 — crop vertically, keep full width. sh = Math.round(img.naturalWidth / targetRatio); sy = Math.round((img.naturalHeight - sh) / 2); } // Don't upscale — if the source crop is smaller than the target, render // at the source crop size so we don't waste bytes on synthetic detail. const outWidth = Math.min(TARGET_WIDTH, sw); const outHeight = Math.min(TARGET_HEIGHT, sh); const canvas = document.createElement('canvas'); canvas.width = outWidth; canvas.height = outHeight; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas context unavailable'); ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outWidth, outHeight); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', QUALITY), ); if (blob) return blob; const jpeg = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', QUALITY), ); if (!jpeg) throw new Error('canvas toBlob returned null'); return jpeg; } finally { URL.revokeObjectURL(url); } } export async function uploadBanner(userId: string, file: File): Promise { if (!file.type.startsWith('image/')) { throw new Error('only image files are accepted'); } if (file.size > MAX_INPUT_BYTES) { throw new Error('image must be 8 MB or smaller'); } const blob = await resizeToBanner(file); return uploadBannerBlob(userId, blob); } // Upload an already-cropped Blob (e.g. from ImageCropDialog). Skips the // legacy center-crop path so the user-chosen framing is preserved. export async function uploadBannerBlob(userId: string, blob: Blob): Promise { const ext = blob.type === 'image/webp' ? 'webp' : 'jpg'; // Random filename so old uploads don't get overwritten before we update // the profile row — Supabase Storage CDN caches by URL, so a fresh path // also forces clients to fetch the new image. const name = userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext; const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, { contentType: blob.type, cacheControl: '604800', upsert: false, }); if (upErr) throw upErr; const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name); return pub.publicUrl; } export const BANNER_TARGET_WIDTH = TARGET_WIDTH; export const BANNER_TARGET_HEIGHT = TARGET_HEIGHT; export const BANNER_MAX_INPUT_BYTES = MAX_INPUT_BYTES; export async function deleteBannerObject(publicUrl: string): Promise { // Public URLs look like // https:///storage/v1/object/public/profile-banners/ // Extract and remove. const marker = '/object/public/' + BUCKET + '/'; const idx = publicUrl.indexOf(marker); if (idx === -1) return; const path = publicUrl.slice(idx + marker.length); const { error } = await supabase.storage.from(BUCKET).remove([path]); if (error) throw error; }