diff --git a/app/(dashboard)/broadcasts/page.tsx b/app/(dashboard)/broadcasts/page.tsx index 61b8595..e019b4b 100644 --- a/app/(dashboard)/broadcasts/page.tsx +++ b/app/(dashboard)/broadcasts/page.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { broadcasts, type Broadcast } from "@/lib/api"; +import { compressImage } from "@/lib/compress-image"; export default function BroadcastsPage() { const [items, setItems] = useState([]); @@ -72,9 +73,17 @@ function Composer({ onDone }: { onDone: () => void }) { const [uploading, setUploading] = useState(false); const [busy, setBusy] = useState(false); - const uploadBanner = async (file: File) => { + const uploadBanner = async (original: File) => { setUploading(true); try { + // Compress before the presigned PUT. A banner renders at roughly 2:1 in + // a ~448px modal, so a phone-camera original is orders of magnitude + // larger than anything that reaches a screen — and every devotee + // downloads it. Falls back to the original on any decode failure, and + // leaves GIFs alone so an animation isn't flattened. + const file = await compressImage(original, { maxDimension: 1280 }).catch( + () => original, + ); const { upload_url, key } = await broadcasts.presignBanner(file.type); const res = await fetch(upload_url, { method: "PUT", diff --git a/app/(dashboard)/reward-gifts/page.tsx b/app/(dashboard)/reward-gifts/page.tsx index 128a75d..63266e6 100644 --- a/app/(dashboard)/reward-gifts/page.tsx +++ b/app/(dashboard)/reward-gifts/page.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import Image from "next/image"; import { toast } from "sonner"; import { rewardGifts, type GiftableReward } from "@/lib/api"; +import { compressImage } from "@/lib/compress-image"; export default function RewardGiftsPage() { const [catalog, setCatalog] = useState(null); @@ -65,6 +66,12 @@ export default function RewardGiftsPage() {

+ + setCatalog((prev) => (prev ? [...prev, reward] : [reward])) + } + /> + {catalog === null ? (

Loading…

) : catalog.length === 0 ? ( @@ -76,7 +83,10 @@ export default function RewardGiftsPage() { {catalog.map((r) => (
  • send(r)} - className="w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" + className="mt-auto w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" > {sending === r.key ? "Sending…" : "Send to everyone"} @@ -106,3 +116,137 @@ export default function RewardGiftsPage() {
    ); } + +/** + * Upload artwork and add it to the catalog. + * + * Creating and sending are deliberately separate steps: a new sticker lands + * in the grid below, where it is sent with the same confirmation as any + * other — so a mistyped name is caught before it reaches everyone. + */ +function CustomGift({ + onCreated, +}: { + onCreated: (reward: GiftableReward) => void; +}) { + const [name, setName] = useState(""); + const [meaning, setMeaning] = useState(""); + const [imageUrl, setImageUrl] = useState(null); + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + + const upload = async (original: File) => { + setUploading(true); + try { + // Same compression as the banner: artwork renders at ~120px in the + // gallery and small again in a chat bubble, so a full-size original is + // wasted bytes on every devotee's device. + const file = await compressImage(original, { maxDimension: 512 }).catch( + () => original, + ); + const { upload_url, image_url } = await rewardGifts.presignImage( + file.type, + ); + const res = await fetch(upload_url, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }); + if (!res.ok) throw new Error(String(res.status)); + setImageUrl(image_url); + toast.success("Artwork uploaded"); + } catch { + toast.error("Could not upload that image"); + } finally { + setUploading(false); + } + }; + + const save = async () => { + if (!name.trim() || !imageUrl) return; + setSaving(true); + try { + const reward = await rewardGifts.create({ + name: name.trim(), + meaning: meaning.trim(), + image_url: imageUrl, + }); + onCreated(reward); + toast.success("Added to the catalog — send it when you're ready"); + setName(""); + setMeaning(""); + setImageUrl(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not save"); + } finally { + setSaving(false); + } + }; + + const field = + "w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"; + + return ( +
    +
    +

    + Add a custom gift +

    +

    + It joins the catalog below. Nobody receives it until you send it. +

    +
    + +
    + setName(e.target.value)} + maxLength={80} + placeholder="Name — e.g. Tulasī Leaf" + className={field} + /> + setMeaning(e.target.value)} + maxLength={200} + placeholder="Meaning (optional)" + className={field} + /> +
    + +
    + { + const f = e.target.files?.[0]; + if (f) void upload(f); + e.target.value = ""; + }} + className={`${field} sm:w-auto`} + /> + {imageUrl && ( + + + + )} + +
    +
    + ); +} diff --git a/lib/api.ts b/lib/api.ts index e12be89..bc399fd 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -451,6 +451,21 @@ export type GiftableReward = { export const rewardGifts = { catalog: () => call("/admin/reward-gifts/catalog").then((r) => r.data), + /** Presign artwork for a custom gift. Returns the final public URL too, + * since sadhna_rewards.image_url stores an absolute address. */ + presignImage: (contentType: string) => + call<{ upload_url: string; image_url: string }>( + "/admin/reward-gifts/image-presign", + { method: "POST", body: JSON.stringify({ content_type: contentType }) }, + ).then((r) => r.data), + + /** Add a sticker to the catalog. It reaches nobody until it is sent. */ + create: (input: { name: string; meaning: string; image_url: string }) => + call("/admin/reward-gifts/catalog", { + method: "POST", + body: JSON.stringify(input), + }).then((r) => r.data), + /** Hand a reward to every account, unopened. Idempotent — a repeat only * reaches people who don't already have it. */ send: (rewardKey: string) => diff --git a/lib/compress-image.ts b/lib/compress-image.ts new file mode 100644 index 0000000..3231136 --- /dev/null +++ b/lib/compress-image.ts @@ -0,0 +1,178 @@ +/** + * Browser-side image compression. + * + * NOTE: a verbatim copy of knowledgebase/lib/compress-image.ts. The two apps + * are separate Next projects with no shared package, and this is dependency- + * free browser code — so it is duplicated rather than reached across for. If a + * third copy appears, that is the point to extract a shared package. + * + * Resizes the longest dimension to `maxDimension` (preserving aspect + * ratio) and re-encodes as JPEG at `quality`. EXIF is stripped as a + * side effect of the canvas round-trip — smaller files plus a tiny + * privacy win since location data doesn't ride along to R2. + * + * Why client-side: every upload path (post images, collection covers, + * avatars, post background swaps) presigns directly to R2. Putting + * the size cut in the browser keeps that flow intact — no proxy hop, + * no queue, no temp storage. The trade-off is that the source bytes + * are lossy from the moment they leave the device; for a photo-share + * product this is the right call. + * + * Safe-by-default failure modes — any of these return the ORIGINAL + * file unchanged rather than blocking the upload: + * + * - The file is not an image (defensive — input is gated by accept= too). + * - The file is a GIF (canvas re-encode would flatten the animation). + * - The decoder fails (e.g. an old browser meeting a HEIC). + * - Compression actually grew the file (rare; tiny already-compressed inputs). + * + * Callers can therefore wrap their existing upload calls without + * adding new error paths. + */ + +export type CompressOptions = { + /** Longest edge in px after resize. Default 1920. */ + maxDimension?: number; + /** JPEG quality 0–1. Default 0.85 (visually lossless for photos). */ + quality?: number; + /** Output MIME type. Default "image/jpeg". */ + mimeType?: string; +}; + +const DEFAULT_MAX = 1920; +const DEFAULT_QUALITY = 0.85; +const DEFAULT_MIME = "image/jpeg"; + +export async function compressImage( + file: File, + opts: CompressOptions = {}, +): Promise { + const maxDimension = opts.maxDimension ?? DEFAULT_MAX; + const quality = opts.quality ?? DEFAULT_QUALITY; + const mimeType = opts.mimeType ?? DEFAULT_MIME; + + // Bail-outs that should return the original untouched. + if (typeof window === "undefined") return file; + if (!file.type.startsWith("image/")) return file; + if (file.type === "image/gif") return file; + + let drawable: ImageBitmap | HTMLImageElement; + try { + drawable = await loadDrawable(file); + } catch { + // Decoder couldn't read this image (e.g. corrupt, or HEIC on a + // browser that doesn't decode HEIC). Send the original up — R2 + // will accept it; the share-image proxy + Cloudflare will sort + // out display sizing downstream. + return file; + } + + const { width: srcW, height: srcH } = drawableDimensions(drawable); + const { width, height } = scaleToFit(srcW, srcH, maxDimension); + + // No-op fast path: already within the cap AND a reasonable size. + // Skip the canvas dance because re-encoding a 200 KB image as JPEG + // q=85 might actually grow it. + if (width === srcW && height === srcH && file.size < 500_000) { + closeBitmap(drawable); + return file; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + closeBitmap(drawable); + return file; + } + // White backdrop so PNG/HEIC transparency doesn't become black + // pixels in the JPEG output. + if (mimeType === "image/jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + } + ctx.drawImage(drawable, 0, 0, width, height); + closeBitmap(drawable); + + const blob = await canvasToBlob(canvas, mimeType, quality); + if (!blob) return file; + // If the "compressed" version ended up larger (small already, or a + // weird input), keep the original. + if (blob.size >= file.size) return file; + + const ext = + mimeType === "image/jpeg" + ? "jpg" + : mimeType.split("/")[1]?.toLowerCase() || "bin"; + const newName = file.name.replace(/\.[^.]+$/, "") + `.${ext}`; + return new File([blob], newName, { + type: mimeType, + lastModified: Date.now(), + }); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +async function loadDrawable( + file: File, +): Promise { + // createImageBitmap is the fast path on modern browsers — it decodes + // off the main thread when supported. + if (typeof createImageBitmap === "function") { + try { + return await createImageBitmap(file); + } catch { + // Fall through to the HTMLImageElement path. + } + } + const url = URL.createObjectURL(file); + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("image decode failed")); + img.src = url; + }); + // Defer revoke so the image stays valid through the next canvas + // draw — we revoke a tick later when it's safe. + setTimeout(() => URL.revokeObjectURL(url), 0); + return img; +} + +function drawableDimensions( + d: ImageBitmap | HTMLImageElement, +): { width: number; height: number } { + if ("naturalWidth" in d) { + return { width: d.naturalWidth, height: d.naturalHeight }; + } + return { width: d.width, height: d.height }; +} + +function scaleToFit( + width: number, + height: number, + max: number, +): { width: number; height: number } { + if (width <= max && height <= max) return { width, height }; + const ratio = width > height ? max / width : max / height; + return { + width: Math.round(width * ratio), + height: Math.round(height * ratio), + }; +} + +function canvasToBlob( + canvas: HTMLCanvasElement, + type: string, + quality: number, +): Promise { + return new Promise((resolve) => { + canvas.toBlob((b) => resolve(b), type, quality); + }); +} + +function closeBitmap(d: ImageBitmap | HTMLImageElement) { + if ("close" in d && typeof d.close === "function") d.close(); +}