feat: image compress before upload
This commit is contained in:
@@ -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.");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void>) {
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -108,9 +182,19 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
state. */}
|
||||
{showSend ? (
|
||||
<ActionButton
|
||||
label="Send"
|
||||
onClick={() => setShareOpen(true)}
|
||||
icon={<HiOutlinePaperAirplane className={ICON_CLASS} />}
|
||||
label={sharePending ? "Preparing share link…" : "Send"}
|
||||
onClick={onSendClick}
|
||||
disabled={sharePending}
|
||||
icon={
|
||||
sharePending ? (
|
||||
<Loader2
|
||||
className={cn(ICON_CLASS, "animate-spin")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<HiOutlinePaperAirplane className={ICON_CLASS} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
@@ -150,21 +234,29 @@ function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-10 inline-flex items-center justify-center rounded-full",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostCardMenu — three-dot overflow menu shown in the post header.
|
||||
*
|
||||
* Items rendered are decided per viewer:
|
||||
*
|
||||
* • Author (you wrote the post) — only `Delete post`.
|
||||
* • Authenticated non-author — `Block @author` and `Report post`.
|
||||
* • Anonymous non-author — `Report post` only.
|
||||
*
|
||||
* The author never sees Block / Report on their own post (you can't
|
||||
* block yourself, and self-report doesn't make sense). Block + Report
|
||||
* are placeholder coming-soon toasts today; the slot is wired up so
|
||||
* shipping the actual flows is a one-line swap each.
|
||||
*
|
||||
* Trigger renders a dropdown on desktop and a bottom drawer on mobile,
|
||||
* matching the SharePostSheet / CollectionsDrawer convention used
|
||||
* elsewhere in the post action UI.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Ban, Flag, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
|
||||
type MenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
export function PostCardMenu({
|
||||
post,
|
||||
onDelete,
|
||||
}: {
|
||||
post: Post;
|
||||
/** Owner-only: when provided AND the viewer is the author, the menu
|
||||
* exposes a Delete option. Parent receives the post id and is
|
||||
* expected to confirm + invoke the delete itself (matches the
|
||||
* existing /u/me Delete-with-modal pattern). */
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const { user, loading } = useAuth();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [popPos, setPopPos] = useState<{ top: number; left: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isOwner = !loading && !!user && user.id === post.user_id;
|
||||
const isAuthed = !loading && !!user;
|
||||
const authorHandle = post.author_handle ?? null;
|
||||
const authorDisplay = authorHandle
|
||||
? `@${authorHandle}`
|
||||
: post.author_name || "this user";
|
||||
|
||||
// Desktop popover positioning: anchor below the trigger, right-aligned.
|
||||
// Recompute every time we open + on resize so a viewport change between
|
||||
// renders doesn't strand the popover at a stale coordinate.
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) {
|
||||
setPopPos(null);
|
||||
return;
|
||||
}
|
||||
function recompute() {
|
||||
const btn = triggerRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const popoverW = 240;
|
||||
const pad = 8;
|
||||
// Right-align the popover to the trigger's right edge — feels
|
||||
// natural for an overflow menu sitting in the top-right corner.
|
||||
let left = rect.right - popoverW;
|
||||
if (left < pad) left = pad;
|
||||
if (left + popoverW > window.innerWidth - pad) {
|
||||
left = window.innerWidth - popoverW - pad;
|
||||
}
|
||||
const top = rect.bottom + 4;
|
||||
setPopPos({ top, left });
|
||||
}
|
||||
recompute();
|
||||
window.addEventListener("resize", recompute);
|
||||
window.addEventListener("scroll", recompute, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", recompute);
|
||||
window.removeEventListener("scroll", recompute, true);
|
||||
};
|
||||
}, [open, isMobile]);
|
||||
|
||||
// Click-outside + Escape to close (desktop only; the drawer's own
|
||||
// overlay handles dismissal on mobile).
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
function onPointer(e: PointerEvent) {
|
||||
const popover = popoverRef.current;
|
||||
const trigger = triggerRef.current;
|
||||
if (popover && popover.contains(e.target as Node)) return;
|
||||
if (trigger && trigger.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
// Delay the listener install one tick so the click that opened us
|
||||
// doesn't immediately close us in the same task.
|
||||
const t = setTimeout(() => {
|
||||
document.addEventListener("pointerdown", onPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
}, 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
document.removeEventListener("pointerdown", onPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, isMobile]);
|
||||
|
||||
// Build the items list AFTER all hooks have run so a conditional
|
||||
// early-return doesn't break the rules-of-hooks contract on
|
||||
// re-renders where viewer state changes.
|
||||
const items: MenuItem[] = [];
|
||||
if (isOwner) {
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
key: "delete",
|
||||
label: "Delete post",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
onDelete(post.id);
|
||||
},
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (isAuthed) {
|
||||
items.push({
|
||||
key: "block",
|
||||
label: `Block ${authorDisplay}`,
|
||||
icon: <Ban className="size-4" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
toast(`Blocking ${authorDisplay} is coming soon`, {
|
||||
duration: 2500,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: "report",
|
||||
label: "Report post",
|
||||
icon: <Flag className="size-4" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
toast("Post reporting is coming soon", { duration: 2500 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Don't render the trigger when there's literally nothing to click —
|
||||
// e.g. an owner-viewer on a surface that doesn't grant delete.
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Post options"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"size-8 inline-flex items-center justify-center rounded-full shrink-0",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
// Mobile: bottom drawer
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
|
||||
<DrawerContent className="bg-card">
|
||||
<DrawerHeader className="text-left border-b border-border pb-3">
|
||||
<DrawerTitle>Post options</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="p-3 space-y-1">
|
||||
{items.map((item) => (
|
||||
<MenuItemButton key={item.key} item={item} mobile />
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: floating popover anchored to the trigger.
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{open && popPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: popPos.top,
|
||||
left: popPos.left,
|
||||
zIndex: 60,
|
||||
}}
|
||||
className={cn(
|
||||
"w-60 rounded-lg border border-border bg-card shadow-lg py-1",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MenuItemButton key={item.key} item={item} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItemButton({
|
||||
item,
|
||||
mobile = false,
|
||||
}: {
|
||||
item: MenuItem;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const destructive = item.variant === "destructive";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 text-left transition-colors rounded-md",
|
||||
mobile ? "px-3 py-3" : "px-3 py-2",
|
||||
destructive
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
destructive ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||
{/* 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. */}
|
||||
<header className="flex items-center gap-3 mb-4">
|
||||
<AuthorAvatar src={post.author_avatar_url ?? null} name={authorName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{authorHandle ? (
|
||||
<Link
|
||||
href={`/u/${authorHandle}`}
|
||||
className="block text-sm font-semibold text-foreground truncate hover:underline"
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-foreground truncate">
|
||||
{authorName}
|
||||
</p>
|
||||
)}
|
||||
{authorHandle ? (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
@{authorHandle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<PostCardMenu post={post} onDelete={onDelete} />
|
||||
</header>
|
||||
|
||||
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
@@ -76,19 +114,7 @@ export function PostCard({
|
||||
<ExternalLink className="size-3" />
|
||||
{displaySourceLabel(post.source_url, post.source_title)}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{new Date(post.created_at).toLocaleDateString()}</span>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(post.id)}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Delete post"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{new Date(post.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
@@ -99,3 +125,30 @@ export function PostCard({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorAvatar({
|
||||
src,
|
||||
name,
|
||||
}: {
|
||||
src: string | null;
|
||||
name: string;
|
||||
}) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="size-9 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initial = name.charAt(0).toUpperCase() || "?";
|
||||
return (
|
||||
<div className="size-9 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10 shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<File> {
|
||||
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<ImageBitmap | HTMLImageElement> {
|
||||
// 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<void>((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<Blob | null> {
|
||||
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();
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user