From 8bd82a0e8acb31ffd1dfa7073924b852304a2641 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sun, 31 May 2026 09:56:53 +0530 Subject: [PATCH] feat: image compress before upload --- components/profile/profile-edit-form.tsx | 7 +- components/selection-toolbar/compose-post.tsx | 8 +- components/social/edit-collection-drawer.tsx | 4 +- components/social/post-actions-footer.tsx | 98 +++++- components/social/post-card-menu.tsx | 280 ++++++++++++++++++ components/social/post-card.tsx | 83 +++++- components/social/share-post-sheet.tsx | 45 +-- components/social/update-background-modal.tsx | 4 +- lib/compress-image.ts | 173 +++++++++++ lib/source-reference.ts | 31 ++ 10 files changed, 673 insertions(+), 60 deletions(-) create mode 100644 components/social/post-card-menu.tsx create mode 100644 lib/compress-image.ts diff --git a/components/profile/profile-edit-form.tsx b/components/profile/profile-edit-form.tsx index 246666b0..f3aca64e 100644 --- a/components/profile/profile-edit-form.tsx +++ b/components/profile/profile-edit-form.tsx @@ -20,6 +20,7 @@ import Image from "next/image"; import { ImagePlus, Save, X } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { auth, AuthError, type User } from "@/lib/auth"; +import { compressImage } from "@/lib/compress-image"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -74,7 +75,11 @@ export function ProfileEditForm({ setError(null); setUploading(true); try { - const publicURL = await auth.uploadAvatar(file); + // Avatars display at ≤ 200 px in every surface, so cap the + // longest edge at 800 px — plenty of retina headroom while + // keeping the stored bytes small. + const compressed = await compressImage(file, { maxDimension: 800 }); + const publicURL = await auth.uploadAvatar(compressed); setAvatarUrl(publicURL); } catch (err) { setError(err instanceof AuthError ? err.message : "Upload failed."); diff --git a/components/selection-toolbar/compose-post.tsx b/components/selection-toolbar/compose-post.tsx index 88874017..8d1acb26 100644 --- a/components/selection-toolbar/compose-post.tsx +++ b/components/selection-toolbar/compose-post.tsx @@ -27,6 +27,7 @@ import { import { posts, SocialError } from "@/lib/social"; import { buildHighlightUrl } from "@/lib/text-highlight"; import { getSourceReference } from "@/lib/source-reference"; +import { compressImage } from "@/lib/compress-image"; import { useSharePost } from "@/components/social/share-post-provider"; const MAX_IMAGES = 5; @@ -75,7 +76,12 @@ export function ComposePost({ await Promise.all( picked.map(async (file, i) => { try { - const url = await posts.uploadImage(file); + // Compress in the browser before the presigned PUT — keeps + // R2 storage bounded, makes share pre-fetch fast, strips + // EXIF as a side effect. Falls back to the original file + // on any decode failure. + const compressed = await compressImage(file); + const url = await posts.uploadImage(compressed); setImages((prev) => { const next = [...prev]; next[baseIndex + i] = { url, uploading: false }; diff --git a/components/social/edit-collection-drawer.tsx b/components/social/edit-collection-drawer.tsx index dbcedf8a..60967436 100644 --- a/components/social/edit-collection-drawer.tsx +++ b/components/social/edit-collection-drawer.tsx @@ -47,6 +47,7 @@ import { type Collection, type Post, } from "@/lib/social"; +import { compressImage } from "@/lib/compress-image"; type SavedShape = { collection: Collection; @@ -142,7 +143,8 @@ export function EditCollectionDrawer({ if (!f) return; setUploadingCover(true); try { - const url = await collections.uploadCover(f); + const compressed = await compressImage(f); + const url = await collections.uploadCover(compressed); setCoverURL(url); } catch (err) { toast.error( diff --git a/components/social/post-actions-footer.tsx b/components/social/post-actions-footer.tsx index 9d1e9679..c552cc59 100644 --- a/components/social/post-actions-footer.tsx +++ b/components/social/post-actions-footer.tsx @@ -22,6 +22,7 @@ */ import { useState } from "react"; +import { Loader2 } from "lucide-react"; import { HiOutlineArrowPath, HiOutlineChatBubbleOvalLeft, @@ -32,6 +33,8 @@ import { import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { posts as postsApi, SocialError, type Post } from "@/lib/social"; +import { createShortLink } from "@/lib/share"; +import { structuredReferenceForPost } from "@/lib/source-reference"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; @@ -49,6 +52,9 @@ export function PostActionsFooter({ post }: { post: Post }) { const isMobile = useMediaQuery("(max-width: 767px)"); const [collectionsOpen, setCollectionsOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); + // Tracks the short-URL fetch on the direct-mobile-share path so the + // Send icon can render a spinner while the user waits. + const [sharePending, setSharePending] = useState(false); // Optimistic local mirror of post.viewer_has_reshared so the swap // happens the instant the user taps Reshare. Reconciles to the // server's value via rollback if the API call fails. @@ -60,6 +66,11 @@ export function PostActionsFooter({ post }: { post: Post }) { // Owners and resharers both see Send — the post is "on their account" // either way, so the action they need is external share, not reshare. const showSend = isOwner || reshared; + // Capability-check the Web Share API once per render — supported on + // mobile Safari/Chrome and some desktop Safari builds. We only want + // the native-share short-circuit on phones. + const nativeShareSupported = + typeof navigator !== "undefined" && typeof navigator.share === "function"; async function withAuth(fn: () => void | Promise) { if (!(await requireAuth())) return; @@ -80,6 +91,69 @@ export function PostActionsFooter({ post }: { post: Post }) { } } + // Direct-mobile share path: + // 1. User taps Send (consumes iOS Safari's user activation). + // 2. We fetch the short URL — short enough to fit comfortably + // inside the same activation window. Loading spinner replaces + // the icon while we wait. + // 3. navigator.share fires within the same handler. iOS sees the + // activation token, opens the system share sheet. + // 4. If activation has expired by the time we call share (slow + // network) or anything else throws non-AbortError, fall back + // to opening the custom in-app sheet so the user isn't stuck. + // + // Desktop / no native share API → straight to the custom sheet, + // which handles its own short-URL fetch + image pre-fetch. + async function onSendClick() { + if (!isMobile || !nativeShareSupported) { + setShareOpen(true); + return; + } + if (sharePending) return; + + const handle = post.author_handle ?? user?.handle ?? ""; + if (!handle) { + // Without a handle we can't build the post URL; fall back to + // the custom sheet which has its own resolution path. + setShareOpen(true); + return; + } + + setSharePending(true); + try { + const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`; + const code = await createShortLink(longPath); + const origin = window.location.origin; + const url = code + ? new URL(`/share/${code}`, origin).toString() + : new URL(longPath, origin).toString(); + + const ref = structuredReferenceForPost( + post.source_url, + post.source_title, + ); + const lines = [`“${post.caption}”`, "", "Source:", ref.title]; + if (ref.detail) lines.push(ref.detail); + lines.push(url); + const text = lines.join("\n"); + const title = ref.detail ? `${ref.title} — ${ref.detail}` : ref.title; + + try { + await navigator.share({ title, text }); + } catch (err) { + // User-cancel = AbortError = leave them alone. Any other + // failure (most often iOS NotAllowedError when the awaited + // fetch overran the user-activation window) → fall back to + // the in-app sheet so the user can still complete the share. + if (err instanceof DOMException && err.name === "AbortError") return; + console.warn("[share] navigator.share failed, opening custom sheet", err); + setShareOpen(true); + } + } finally { + setSharePending(false); + } + } + return ( <>
@@ -108,9 +182,19 @@ export function PostActionsFooter({ post }: { post: Post }) { state. */} {showSend ? ( setShareOpen(true)} - icon={} + label={sharePending ? "Preparing share link…" : "Send"} + onClick={onSendClick} + disabled={sharePending} + icon={ + sharePending ? ( + + ) : ( + + ) + } /> ) : ( void; icon: React.ReactNode; + /** Greys out the button and blocks clicks. Used by the Send button + * while the short URL is being resolved on the mobile native-share + * path so users can't fire navigator.share before the URL is + * ready. */ + disabled?: boolean; }) { return ( + ); + + // Mobile: bottom drawer + if (isMobile) { + return ( + <> + {trigger} + setOpen(o)}> + + + Post options + +
+ {items.map((item) => ( + + ))} +
+
+
+ + ); + } + + // Desktop: floating popover anchored to the trigger. + return ( + <> + {trigger} + {open && popPos + ? createPortal( +
+ {items.map((item) => ( + + ))} +
, + document.body, + ) + : null} + + ); +} + +function MenuItemButton({ + item, + mobile = false, +}: { + item: MenuItem; + mobile?: boolean; +}) { + const destructive = item.variant === "destructive"; + return ( + + ); +} diff --git a/components/social/post-card.tsx b/components/social/post-card.tsx index eee899bd..b153ab55 100644 --- a/components/social/post-card.tsx +++ b/components/social/post-card.tsx @@ -3,10 +3,16 @@ /** * PostCard — renders one post in the public feed and the /u/me feed. * + * • Author header — avatar + display name + @handle on the left, + * three-dot overflow menu on the right. Menu contents vary by + * viewer (see PostCardMenu): author sees Delete; signed-in + * non-author sees Block + Report; anonymous sees Report. * • Caption in serif (locked, can't be edited) * • Image grid (max 5; collapses to one column on the cheap) * • "Source" link back to the page the quote came from - * • Optional onDelete callback shown only when the owner is viewing + * • Optional onDelete callback — passed through to PostCardMenu so + * Delete actually does something on owner views (parent confirms + + * calls the API; same pattern as before the menu refactor). * * The source link always lands the user on the original passage with `?hl=` * highlighting. If the stored source_url already carries `?hl=` we use it @@ -16,11 +22,12 @@ import Image from "next/image"; import Link from "next/link"; -import { Trash2, ExternalLink } from "lucide-react"; +import { ExternalLink } from "lucide-react"; import type { Post } from "@/lib/social"; import { displaySourceLabel } from "@/lib/source-reference"; import { appendHighlight, hasHighlight } from "@/lib/text-highlight"; import { PostActionsFooter } from "./post-actions-footer"; +import { PostCardMenu } from "./post-card-menu"; export function PostCard({ post, @@ -34,8 +41,39 @@ export function PostCard({ ? post.source_url : appendHighlight(post.source_url, post.caption); + const authorName = post.author_name || "Unknown"; + const authorHandle = post.author_handle ?? null; + return (
+ {/* Author header — identity on the left, overflow menu on the + right. The menu trigger only renders when there are items to + show (delete for owners, block/report for everyone else), so + a view with nothing to expose simply hides the dot. */} +
+ +
+ {authorHandle ? ( + + {authorName} + + ) : ( +

+ {authorName} +

+ )} + {authorHandle ? ( +

+ @{authorHandle} +

+ ) : null} +
+ +
+

“{post.caption}”

@@ -76,19 +114,7 @@ export function PostCard({ {displaySourceLabel(post.source_url, post.source_title)} -
- {new Date(post.created_at).toLocaleDateString()} - {onDelete ? ( - - ) : null} -
+ {new Date(post.created_at).toLocaleDateString()}
{/* Action footer — like / comment / reshare on the left, add to @@ -99,3 +125,30 @@ export function PostCard({ ); } + +function AuthorAvatar({ + src, + name, +}: { + src: string | null; + name: string; +}) { + if (src) { + return ( + {name} + ); + } + const initial = name.charAt(0).toUpperCase() || "?"; + return ( +
+ {initial} +
+ ); +} diff --git a/components/social/share-post-sheet.tsx b/components/social/share-post-sheet.tsx index 03a2e86c..6609fbc1 100644 --- a/components/social/share-post-sheet.tsx +++ b/components/social/share-post-sheet.tsx @@ -67,7 +67,7 @@ import type { Post } from "@/lib/social"; import { createShortLink } from "@/lib/share"; import { displaySourceLabel, - referenceFromUrl, + structuredReferenceForPost, } from "@/lib/source-reference"; import { useAuth } from "@/components/auth/auth-provider"; @@ -85,43 +85,12 @@ export function SharePostSheet({ // `reference` is the single-line citation used for UI chrome — the // drawer header, email subject, etc. where multi-line doesn't render. const reference = displaySourceLabel(post.source_url, post.source_title); - // `structuredRef` is the same citation split into its `{ title, detail }` - // halves so the share text can lay them on separate lines — matches - // the formatCopyText pattern from selection-toolbar/shared.tsx: - // - // "caption text" - // - // Source: - // Letter to Mukunda - // 15 January, 1968 · Calcutta - // short URL - // - // Two routes to the structured shape: - // - // 1. Book URLs → `referenceFromUrl` parses the path and returns - // a clean { title, detail } (e.g. Bhagavad-gītā / - // Chapter 2, Verse 13). - // 2. Everything else (letters, essays, transcriptions, magazines) - // → `source_title` was composed by compose-post.tsx - // as "Title — Detail" using ` — ` as the join. - // We split on the first ` — ` to recover the - // structured fields. Falls back to title-only if - // no separator is present (book-index pages, - // legacy posts, etc.). - const structuredRef = useMemo<{ title: string; detail: string | null }>( - () => { - const r = referenceFromUrl(post.source_url); - if (r) return r; - const stored = post.source_title?.trim() || "Source"; - const idx = stored.indexOf(" — "); - if (idx === -1) { - return { title: stored, detail: null }; - } - return { - title: stored.slice(0, idx).trim(), - detail: stored.slice(idx + 3).trim() || null, - }; - }, + // Structured `{ title, detail }` for the multi-line "Source:" block + // in the share payload. Same helper that the direct-mobile native + // share path in post-actions-footer.tsx uses, so both surfaces + // produce byte-identical citation lines. + const structuredRef = useMemo( + () => structuredReferenceForPost(post.source_url, post.source_title), [post.source_url, post.source_title], ); const firstImage = post.images?.[0]?.url ?? null; diff --git a/components/social/update-background-modal.tsx b/components/social/update-background-modal.tsx index fc716408..d089116c 100644 --- a/components/social/update-background-modal.tsx +++ b/components/social/update-background-modal.tsx @@ -26,6 +26,7 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { posts, SocialError, type Post } from "@/lib/social"; +import { compressImage } from "@/lib/compress-image"; type Busy = "upload" | "remove" | null; @@ -69,7 +70,8 @@ export function UpdateBackgroundModal({ if (!file) return; setBusy("upload"); try { - const url = await posts.uploadImage(file); + const compressed = await compressImage(file); + const url = await posts.uploadImage(compressed); const updated = await posts.replaceImages(post.id, [url]); toast.success("Background updated"); onUpdated?.(updated); diff --git a/lib/compress-image.ts b/lib/compress-image.ts new file mode 100644 index 00000000..10e16b44 --- /dev/null +++ b/lib/compress-image.ts @@ -0,0 +1,173 @@ +/** + * Browser-side image compression. + * + * 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(); +} diff --git a/lib/source-reference.ts b/lib/source-reference.ts index 6edc76a1..79e2b056 100644 --- a/lib/source-reference.ts +++ b/lib/source-reference.ts @@ -351,6 +351,37 @@ export function referenceFromUrl( return { title: bookName, detail: null }; } +/** + * Resolve a post's source into structured `{ title, detail }` for + * multi-line share/copy payloads. Mirrors the formatCopyText pattern + * from selection-toolbar/shared.tsx so Send + Copy produce the same + * citation block. + * + * Two routes to the structured shape: + * + * 1. Book URLs → `referenceFromUrl` parses the path. + * 2. Anything else (letters, essays, transcriptions, magazines): + * `source_title` was composed by compose-post.tsx as + * "Title — Detail" using ` — ` as the join. Split on the first + * occurrence to recover the structured fields. + * + * Falls back to title-only with no detail when no separator is present. + */ +export function structuredReferenceForPost( + sourceUrl: string, + sourceTitle: string | null | undefined, +): { title: string; detail: string | null } { + const r = referenceFromUrl(sourceUrl); + if (r) return r; + const stored = sourceTitle?.trim() || "Source"; + const idx = stored.indexOf(" — "); + if (idx === -1) return { title: stored, detail: null }; + return { + title: stored.slice(0, idx).trim(), + detail: stored.slice(idx + 3).trim() || null, + }; +} + /** * Render a one-line citation string for a post/bookmark card. *