feat: add user centric features
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectionGridCard — square collection card for the 2-per-row grid view.
|
||||
*
|
||||
* • With cover: cover fills the square; bottom-up overlay for legibility.
|
||||
* • Without cover: the same flat palette-aware surface that post cards
|
||||
* fall back to — pure white on light themes, palette-specific dark
|
||||
* contrast on dark themes (see `--quote-card-bg`).
|
||||
*
|
||||
* Visibility badge (Globe/Lock) sits top-right. Title + count read
|
||||
* bottom-centre in white over the cover image's dark overlay; on the
|
||||
* flat fallback they use `text-foreground` so they invert cleanly with
|
||||
* the theme.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Collection } from "@/lib/social";
|
||||
import { quoteCardBgStyle } from "./post-card-shared";
|
||||
|
||||
export function CollectionGridCard({
|
||||
collection: c,
|
||||
href,
|
||||
}: {
|
||||
collection: Collection;
|
||||
href: string;
|
||||
}) {
|
||||
const hasCover = !!c.cover_url;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group relative block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all select-none"
|
||||
>
|
||||
{hasCover ? (
|
||||
<>
|
||||
<Image
|
||||
src={c.cover_url!}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, 320px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
{/* Visibility badge — black pill works on both image overlay and
|
||||
flat surface (white or dark). */}
|
||||
<div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
|
||||
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
|
||||
{c.is_public ? "Public" : "Private"}
|
||||
</div>
|
||||
|
||||
{/* Title + count — text colour follows the surface beneath. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col items-center justify-center text-center px-5 sm:px-7",
|
||||
hasCover ? "text-white" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] text-lg sm:text-2xl font-semibold leading-snug line-clamp-4 break-words",
|
||||
hasCover && "drop-shadow-sm",
|
||||
)}
|
||||
>
|
||||
{c.title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[0.65rem] uppercase tracking-[0.22em] font-medium",
|
||||
hasCover ? "text-white/85" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectionScrollCard — Instagram-style horizontal carousel for one
|
||||
* collection.
|
||||
*
|
||||
* • Slide 1 is the collection's "cover" — cover image (or themed
|
||||
* gradient if none) overlaid with the title and item count.
|
||||
* • Slides 2..N are PostGridCards for each post in the collection.
|
||||
*
|
||||
* Posts are fetched lazily: an IntersectionObserver triggers
|
||||
* `collections.get(id)` the first time the carousel scrolls into view,
|
||||
* so a feed of many collections doesn't blow N+1 requests on mount.
|
||||
*
|
||||
* The carousel uses native CSS scroll-snap (horizontal, mandatory) so
|
||||
* touch swipes feel right on mobile. A page-dot indicator below tracks
|
||||
* the current slide.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { collections, type Collection, type Post } from "@/lib/social";
|
||||
import { PostGridCard } from "./post-grid-card";
|
||||
import { quoteCardBgStyle } from "./post-card-shared";
|
||||
|
||||
type FetchState = "idle" | "loading" | "loaded" | "error";
|
||||
|
||||
export function CollectionScrollCard({
|
||||
collection: c,
|
||||
href,
|
||||
initialPosts,
|
||||
onEdit,
|
||||
}: {
|
||||
collection: Collection;
|
||||
/** Link target for the "Open" footer + cover slide click-through. */
|
||||
href: string;
|
||||
/** Pre-loaded posts — skip the lazy fetch when the parent already has
|
||||
* them (e.g. the single-collection page renders one card after its
|
||||
* own collections.get call). */
|
||||
initialPosts?: Post[];
|
||||
/** When provided, shows a compact Edit chip in the cover slide's
|
||||
* bottom-right corner. Owner-only — caller decides whether to pass it. */
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const [posts, setPosts] = useState<Post[]>(initialPosts ?? []);
|
||||
const [fetchState, setFetchState] = useState<FetchState>(
|
||||
initialPosts ? "loaded" : "idle",
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Reconcile when the parent supplies fresh posts (e.g. after editing).
|
||||
useEffect(() => {
|
||||
if (!initialPosts) return;
|
||||
setPosts(initialPosts);
|
||||
setFetchState("loaded");
|
||||
}, [initialPosts]);
|
||||
|
||||
// Fetch posts the first time the carousel enters the viewport (with a
|
||||
// 200px rootMargin so we kick it off just before it's visible). Skipped
|
||||
// entirely when `initialPosts` was provided.
|
||||
useEffect(() => {
|
||||
if (fetchState !== "idle") return;
|
||||
const el = cardRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
observer.disconnect();
|
||||
setFetchState("loading");
|
||||
collections
|
||||
.get(c.id)
|
||||
.then(({ posts: rows }) => {
|
||||
setPosts(rows ?? []);
|
||||
setFetchState("loaded");
|
||||
})
|
||||
.catch(() => setFetchState("error"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [c.id, fetchState]);
|
||||
|
||||
// Track which slide is currently snapped. We use scrollLeft / slideWidth
|
||||
// rather than another observer because the slides are full-width siblings
|
||||
// — much cheaper to compute.
|
||||
function onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const slideWidth = el.offsetWidth;
|
||||
if (slideWidth === 0) return;
|
||||
setActiveIndex(Math.round(el.scrollLeft / slideWidth));
|
||||
}
|
||||
|
||||
function scrollByOne(direction: 1 | -1) {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: el.offsetWidth * direction, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const slideCount = 1 + posts.length;
|
||||
const showArrows = slideCount > 1;
|
||||
|
||||
return (
|
||||
<article ref={cardRef} className="space-y-3">
|
||||
<div className="relative max-w-xl mx-auto">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"flex overflow-x-auto snap-x snap-mandatory rounded-2xl",
|
||||
// Hide scrollbar — the carousel is gesture-driven; the dot
|
||||
// indicator below tracks position.
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{/* Cover slide */}
|
||||
<CoverSlide collection={c} href={href} onEdit={onEdit} />
|
||||
|
||||
{/* Post slides (or placeholders while loading) */}
|
||||
{fetchState === "loaded" &&
|
||||
posts.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="w-full shrink-0 snap-start p-px"
|
||||
aria-roledescription="slide"
|
||||
>
|
||||
<PostGridCard post={p} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{fetchState === "loading" ? (
|
||||
<div className="w-full shrink-0 snap-start aspect-square rounded-2xl bg-card ring-1 ring-foreground/10 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showArrows ? (
|
||||
<>
|
||||
<CarouselArrow
|
||||
direction="prev"
|
||||
onClick={() => scrollByOne(-1)}
|
||||
disabled={activeIndex === 0}
|
||||
/>
|
||||
<CarouselArrow
|
||||
direction="next"
|
||||
onClick={() => scrollByOne(1)}
|
||||
disabled={activeIndex >= slideCount - 1}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Dot indicator */}
|
||||
{slideCount > 1 ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: slideCount }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-full transition-all",
|
||||
i === activeIndex
|
||||
? "size-1.5 bg-foreground"
|
||||
: "size-1.5 bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Slide pieces ────────────────────────────────────────────────────────
|
||||
|
||||
function CoverSlide({
|
||||
collection: c,
|
||||
href,
|
||||
onEdit,
|
||||
}: {
|
||||
collection: Collection;
|
||||
href: string;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
// The slide wrapper hosts the Link AND any owner-action chip as
|
||||
// siblings. Nesting a button inside the Link is an a11y anti-pattern
|
||||
// (interactive nested in interactive), so we keep them peers and let
|
||||
// DOM z-order put the chip on top of the Link's bottom-right corner.
|
||||
return (
|
||||
<div className="w-full shrink-0 snap-start relative aspect-square">
|
||||
<Link
|
||||
href={href}
|
||||
className="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-foreground/10 select-none"
|
||||
aria-label={`Open collection ${c.title}`}
|
||||
>
|
||||
{c.cover_url ? (
|
||||
<>
|
||||
<Image
|
||||
src={c.cover_url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/40 to-black/10" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
|
||||
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
|
||||
Collection
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col p-6 sm:p-8",
|
||||
// Text colour follows the surface underneath: white over the
|
||||
// image's dark overlay, theme foreground over the flat
|
||||
// fallback so it inverts cleanly with the palette.
|
||||
c.cover_url ? "text-white" : "text-foreground",
|
||||
c.cover_url
|
||||
? "justify-end"
|
||||
: "justify-center items-center text-center",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold leading-tight line-clamp-3 break-words",
|
||||
c.cover_url && "drop-shadow-md",
|
||||
)}
|
||||
>
|
||||
{c.title}
|
||||
</p>
|
||||
{c.description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-sm line-clamp-2",
|
||||
c.cover_url ? "text-white/85 max-w-md" : "text-muted-foreground max-w-md mx-auto",
|
||||
)}
|
||||
>
|
||||
{c.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 text-[0.7rem] uppercase tracking-[0.22em] font-semibold",
|
||||
c.cover_url ? "text-white/75" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse →
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit collection"
|
||||
title="Edit collection"
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 z-10 inline-flex items-center gap-1",
|
||||
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
|
||||
"text-white text-[0.7rem] font-medium uppercase tracking-[0.16em]",
|
||||
"px-2.5 py-1 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselArrow({
|
||||
direction,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
direction: "prev" | "next";
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
|
||||
className={cn(
|
||||
"hidden md:inline-flex absolute top-1/2 -translate-y-1/2 size-9 rounded-full",
|
||||
"bg-black/40 text-white items-center justify-center backdrop-blur-sm",
|
||||
"hover:bg-black/60 transition-all",
|
||||
"disabled:opacity-0 disabled:pointer-events-none",
|
||||
direction === "prev" ? "left-3" : "right-3",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectionViews — grid-only renderer for collection lists.
|
||||
*
|
||||
* `hrefFor(c)` chooses where each card links: the owner page sends users
|
||||
* to /u/me/collections/<id>; the public profile sends them to /c/<id>.
|
||||
* The carousel "scroll view" is reached by clicking through to the
|
||||
* collection detail page.
|
||||
*/
|
||||
|
||||
import type { Collection } from "@/lib/social";
|
||||
import { CollectionGridCard } from "./collection-grid-card";
|
||||
|
||||
export function CollectionViews({
|
||||
collections,
|
||||
hrefFor,
|
||||
emptyMessage,
|
||||
}: {
|
||||
collections: Collection[];
|
||||
hrefFor: (c: Collection) => string;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emptyMessage ?? "No collections yet."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
{collections.map((c) => (
|
||||
<CollectionGridCard key={c.id} collection={c} href={hrefFor(c)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EditCollectionDrawer — single-stop editor for a collection.
|
||||
*
|
||||
* • Title, description, public/private toggle
|
||||
* • Cover image upload (signed-URL PUT to R2)
|
||||
* • Reorder posts via drag-and-drop, toggle posts in/out of the collection
|
||||
* • Delete button at the bottom (with confirm modal)
|
||||
*
|
||||
* Right-side panel on `md+`, bottom drawer on mobile. The body scrolls;
|
||||
* header and footer stay pinned so action buttons are always reachable.
|
||||
*
|
||||
* Drag-and-drop is powered by framer-motion's Reorder primitives so we
|
||||
* get touch + mouse support without an extra dependency. Each row has a
|
||||
* dedicated grip handle (Reorder.Item with `dragListener={false}` plus
|
||||
* `dragControls`) so the rest of the row stays clickable for toggling.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import {
|
||||
Globe,
|
||||
GripVertical,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Lock,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import {
|
||||
collections,
|
||||
posts as postsApi,
|
||||
SocialError,
|
||||
type Collection,
|
||||
type Post,
|
||||
} from "@/lib/social";
|
||||
|
||||
type SavedShape = {
|
||||
collection: Collection;
|
||||
posts: Post[];
|
||||
};
|
||||
|
||||
export function EditCollectionDrawer({
|
||||
open,
|
||||
onClose,
|
||||
collection,
|
||||
currentPostIds,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
collection: Collection;
|
||||
/** Post ids currently in the collection, in display order. Used to
|
||||
* seed the drag-sortable list and to diff against the user's
|
||||
* selection + new ordering on save. */
|
||||
currentPostIds: string[];
|
||||
onSaved: (next: SavedShape) => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const direction: "bottom" | "right" = isMobile ? "bottom" : "right";
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState(collection.title);
|
||||
const [description, setDescription] = useState(collection.description ?? "");
|
||||
const [isPublic, setIsPublic] = useState(collection.is_public);
|
||||
const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url);
|
||||
|
||||
// Source of truth for the in-collection set + order: a plain array of
|
||||
// post ids. We push/pull from this when toggling, and reorder it on
|
||||
// drag. On save we diff against `currentPostIds` to find adds/removes
|
||||
// and push the final order to /items/order.
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(currentPostIds);
|
||||
|
||||
const [allPosts, setAllPosts] = useState<Post[] | null>(null);
|
||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingCover, setUploadingCover] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle(collection.title);
|
||||
setDescription(collection.description ?? "");
|
||||
setIsPublic(collection.is_public);
|
||||
setCoverURL(collection.cover_url);
|
||||
setOrderedIds(currentPostIds);
|
||||
setError(null);
|
||||
}, [open, collection, currentPostIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || allPosts !== null) return;
|
||||
setLoadingPosts(true);
|
||||
postsApi
|
||||
.mine()
|
||||
.then((rows) => setAllPosts(rows ?? []))
|
||||
.catch((err) =>
|
||||
setError(err instanceof SocialError ? err.message : "Could not load posts."),
|
||||
)
|
||||
.finally(() => setLoadingPosts(false));
|
||||
}, [open, allPosts]);
|
||||
|
||||
// Partition the user's posts into "in the collection" (ordered by
|
||||
// `orderedIds`) and "available to add" (everything else, stable).
|
||||
const { inCollection, available } = useMemo(() => {
|
||||
if (!allPosts) return { inCollection: [] as Post[], available: [] as Post[] };
|
||||
const byId = new Map(allPosts.map((p) => [p.id, p]));
|
||||
const selected = new Set(orderedIds);
|
||||
const inCollection = orderedIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((p): p is Post => !!p);
|
||||
const available = allPosts.filter((p) => !selected.has(p.id));
|
||||
return { inCollection, available };
|
||||
}, [allPosts, orderedIds]);
|
||||
|
||||
function addToCollection(id: string) {
|
||||
setOrderedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
}
|
||||
|
||||
function removeFromCollection(id: string) {
|
||||
setOrderedIds((prev) => prev.filter((x) => x !== id));
|
||||
}
|
||||
|
||||
async function onCoverChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
if (!f) return;
|
||||
setUploadingCover(true);
|
||||
try {
|
||||
const url = await collections.uploadCover(f);
|
||||
setCoverURL(url);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Cover upload failed.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingCover(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCover() {
|
||||
setCoverURL(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const before = new Set(currentPostIds);
|
||||
const after = new Set(orderedIds);
|
||||
const toAdd = orderedIds.filter((id) => !before.has(id));
|
||||
const toRemove = currentPostIds.filter((id) => !after.has(id));
|
||||
|
||||
await collections.update(collection.id, {
|
||||
title,
|
||||
description,
|
||||
is_public: isPublic,
|
||||
cover_url: coverURL ?? "",
|
||||
});
|
||||
// Sequential mutations — the count trigger races on parallel calls.
|
||||
for (const id of toAdd) await collections.addItem(collection.id, id);
|
||||
for (const id of toRemove)
|
||||
await collections.removeItem(collection.id, id);
|
||||
// Final position pass — overwrites whatever positions addItem
|
||||
// assigned (MAX+1) so the user's drag order wins.
|
||||
if (orderedIds.length > 0) {
|
||||
await collections.reorderItems(collection.id, orderedIds);
|
||||
}
|
||||
|
||||
const fresh = await collections.get(collection.id);
|
||||
onSaved({ collection: fresh.collection, posts: fresh.posts ?? [] });
|
||||
toast.success("Collection updated");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof SocialError ? err.message : "Save failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
try {
|
||||
await collections.delete(collection.id);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof SocialError ? err.message : "Could not delete.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && !saving && onClose()}
|
||||
direction={direction}
|
||||
shouldScaleBackground={direction === "bottom"}
|
||||
>
|
||||
<DrawerContent
|
||||
direction={direction}
|
||||
className={
|
||||
direction === "bottom"
|
||||
? "max-h-[90vh]"
|
||||
: "max-w-md sm:max-w-lg"
|
||||
}
|
||||
>
|
||||
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
|
||||
<DrawerTitle>Edit collection</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
disabled={saving}
|
||||
className="size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
|
||||
{/* Cover */}
|
||||
<section className="flex items-start gap-4">
|
||||
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
|
||||
{coverURL ? (
|
||||
<Image
|
||||
src={coverURL}
|
||||
alt=""
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.6rem] uppercase tracking-[0.2em] text-muted-foreground text-center px-2">
|
||||
No cover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 min-w-0">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
hidden
|
||||
onChange={onCoverChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 h-9 text-sm text-foreground hover:bg-muted border border-border w-fit disabled:opacity-50"
|
||||
>
|
||||
{uploadingCover ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="size-4" />
|
||||
)}
|
||||
{coverURL ? "Change cover" : "Add cover"}
|
||||
</button>
|
||||
{coverURL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCover}
|
||||
className="text-xs text-muted-foreground hover:text-destructive w-fit"
|
||||
>
|
||||
Remove cover
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Title */}
|
||||
<section className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Description */}
|
||||
<section className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Optional"
|
||||
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Visibility */}
|
||||
<section>
|
||||
<label className="flex items-center gap-2.5 text-sm cursor-pointer text-foreground">
|
||||
<Checkbox
|
||||
checked={isPublic}
|
||||
onCheckedChange={setIsPublic}
|
||||
ariaLabel="Public collection"
|
||||
/>
|
||||
{isPublic ? (
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Public — anyone with the link can view
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Posts in this collection (drag-sortable) */}
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
In this collection
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{orderedIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{loadingPosts ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : inCollection.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No posts in this collection yet — pick one from below.
|
||||
</p>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={orderedIds}
|
||||
onReorder={setOrderedIds}
|
||||
className="rounded-lg border border-border divide-y divide-border bg-background"
|
||||
as="ul"
|
||||
>
|
||||
{inCollection.map((p) => (
|
||||
<SortableRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
onRemove={() => removeFromCollection(p.id)}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Available posts */}
|
||||
{available.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Add posts
|
||||
</label>
|
||||
<ul className="rounded-lg border border-border divide-y divide-border bg-background">
|
||||
{available.map((p) => (
|
||||
<AvailableRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
onAdd={() => addToCollection(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="shrink-0 border-t border-border p-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 h-10 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center rounded-md px-4 h-10 text-sm text-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving || uploadingCover || title.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-4 h-10 bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
title={`Delete "${collection.title}"?`}
|
||||
message="The collection will be removed permanently. The posts inside stay on your feed."
|
||||
confirmLabel="Delete collection"
|
||||
onConfirm={performDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row pieces ──────────────────────────────────────────────────────────
|
||||
|
||||
function SortableRow({
|
||||
post,
|
||||
onRemove,
|
||||
}: {
|
||||
post: Post;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
// dragListener={false} disables the default whole-item drag — only the
|
||||
// grip handle starts a drag via dragControls.start.
|
||||
const controls = useDragControls();
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={post.id}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors bg-background select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
aria-label="Drag to reorder"
|
||||
title="Drag to reorder"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<Checkbox
|
||||
checked
|
||||
onCheckedChange={() => onRemove()}
|
||||
ariaLabel="Remove from collection"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<PostSummary post={post} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function AvailableRow({ post, onAdd }: { post: Post; onAdd: () => void }) {
|
||||
return (
|
||||
<li>
|
||||
<label className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors cursor-pointer">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => onAdd()}
|
||||
ariaLabel="Add to collection"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<PostSummary post={post} />
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSummary({ post }: { post: Post }) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-foreground line-clamp-2 break-words">
|
||||
{post.caption}
|
||||
</span>
|
||||
{post.source_title ? (
|
||||
<span className="block text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground mt-1 truncate">
|
||||
{post.source_title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PageBookmarks — persistent in-text bookmark highlighting + floating X.
|
||||
*
|
||||
* For every bookmark the logged-in user has whose `source_url` path
|
||||
* matches the current pathname, we:
|
||||
*
|
||||
* 1. Wrap the passage in a `<mark data-bookmark-id="…">` with a violet
|
||||
* wash so the text stays highlighted across the whole session.
|
||||
* 2. Anchor a floating "Bookmarked × remove" pill 32px above the first
|
||||
* mark, scrolling with the page.
|
||||
* 3. Remove both the mark AND the pill when the user clicks ×.
|
||||
*
|
||||
* Cross-component sync: the selection toolbar dispatches
|
||||
* `spsays:bookmarks-changed` whenever it creates a bookmark; we re-fetch
|
||||
* and re-mark on that event, so a freshly-bookmarked passage lights up
|
||||
* immediately without a page reload.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Bookmark, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import {
|
||||
bookmarks as bookmarksApi,
|
||||
type Bookmark as BookmarkRec,
|
||||
} from "@/lib/social";
|
||||
import {
|
||||
decodeTextFragment,
|
||||
markFragmentAsBookmark,
|
||||
removeBookmarkMarks,
|
||||
} from "@/lib/text-highlight";
|
||||
|
||||
type Marker = {
|
||||
bookmark: BookmarkRec;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
export function PageBookmarks() {
|
||||
const { user, loading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [list, setList] = useState<BookmarkRec[]>([]);
|
||||
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||
// Track which bookmark ids have already been applied to the DOM so we
|
||||
// don't double-wrap when state churns (e.g. an optimistic delete that
|
||||
// then errors and gets restored).
|
||||
const appliedIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const onThisPage = useMemo(
|
||||
() => list.filter((b) => pathOf(b.source_url) === pathname),
|
||||
[list, pathname],
|
||||
);
|
||||
|
||||
// Fetch bookmarks once per user. The toolbar fires a custom event when
|
||||
// it creates a new one so we refresh without polling.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
setList([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refetch = () =>
|
||||
bookmarksApi
|
||||
.list()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setList(rows ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
/* overlay is non-essential — swallow errors */
|
||||
});
|
||||
refetch();
|
||||
const onChanged = () => {
|
||||
void refetch();
|
||||
};
|
||||
window.addEventListener("spsays:bookmarks-changed", onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("spsays:bookmarks-changed", onChanged);
|
||||
};
|
||||
}, [user, loading]);
|
||||
|
||||
// Apply marks + measure icon positions. Runs whenever the relevant
|
||||
// bookmarks for this page change, OR when the viewport resizes so a
|
||||
// reflowed paragraph doesn't strand the floating icon.
|
||||
const applyAndMeasure = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Garbage-collect marks for bookmarks that no longer exist on this
|
||||
// page (deleted, or navigated to a different URL).
|
||||
const wantedIds = new Set(onThisPage.map((b) => b.id));
|
||||
for (const id of appliedIdsRef.current) {
|
||||
if (!wantedIds.has(id)) {
|
||||
removeBookmarkMarks(id);
|
||||
appliedIdsRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const next: Marker[] = [];
|
||||
for (const bookmark of onThisPage) {
|
||||
const hl = hlOf(bookmark.source_url);
|
||||
if (!hl) continue;
|
||||
const fragment = decodeTextFragment(hl);
|
||||
if (!fragment) continue;
|
||||
|
||||
// Wrap text in <mark> if not already wrapped. Idempotent.
|
||||
const marks = markFragmentAsBookmark(fragment, bookmark.id);
|
||||
if (marks.length === 0) continue;
|
||||
appliedIdsRef.current.add(bookmark.id);
|
||||
|
||||
// Anchor the floating icon to the first mark's bounding rect.
|
||||
const rect = marks[0].getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) continue;
|
||||
next.push({
|
||||
bookmark,
|
||||
top: rect.top + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
setMarkers(next);
|
||||
}, [onThisPage]);
|
||||
|
||||
// Run the apply/measure cycle when this page's bookmarks change. Some
|
||||
// pages stream content in (the verses page), so we retry a few times to
|
||||
// catch text nodes added shortly after mount.
|
||||
useEffect(() => {
|
||||
if (onThisPage.length === 0) {
|
||||
// No bookmarks for this page — strip any leftover marks (e.g. from
|
||||
// a stale render before list rehydrated) and stop.
|
||||
removeBookmarkMarks();
|
||||
appliedIdsRef.current.clear();
|
||||
setMarkers([]);
|
||||
return;
|
||||
}
|
||||
let attempts = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const tick = () => {
|
||||
applyAndMeasure();
|
||||
attempts++;
|
||||
if (attempts < 6) timer = setTimeout(tick, 300);
|
||||
};
|
||||
tick();
|
||||
window.addEventListener("resize", applyAndMeasure);
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
window.removeEventListener("resize", applyAndMeasure);
|
||||
};
|
||||
}, [onThisPage, applyAndMeasure]);
|
||||
|
||||
// Belt-and-braces: when this component unmounts (route change), tear
|
||||
// down every mark so the next page starts clean.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeBookmarkMarks();
|
||||
appliedIdsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function remove(id: string) {
|
||||
const previous = list;
|
||||
setList((rows) => rows.filter((b) => b.id !== id));
|
||||
// Yank the in-text mark immediately for snappy feedback. The
|
||||
// applyAndMeasure effect would also catch this, but we don't want a
|
||||
// visible flash of the still-highlighted text while it runs.
|
||||
removeBookmarkMarks(id);
|
||||
appliedIdsRef.current.delete(id);
|
||||
try {
|
||||
await bookmarksApi.delete(id);
|
||||
toast.success("Bookmark removed");
|
||||
} catch {
|
||||
setList(previous);
|
||||
toast.error("Could not remove bookmark");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{markers.map((m) => (
|
||||
<div
|
||||
key={m.bookmark.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
// top = top-of-text minus pill height (~26) minus tail (6) minus
|
||||
// a 4px breathing gap so the tail tip floats just above the
|
||||
// highlighted line.
|
||||
top: m.top - 36,
|
||||
left: m.left,
|
||||
zIndex: 40,
|
||||
}}
|
||||
className="pointer-events-none"
|
||||
>
|
||||
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
|
||||
<Bookmark className="size-3.5 text-white" />
|
||||
<span className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold text-white">
|
||||
Bookmarked
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(m.bookmark.id)}
|
||||
aria-label="Remove bookmark"
|
||||
className="size-5 ml-0.5 inline-flex items-center justify-center rounded-full text-white/80 hover:bg-white/20 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Teardrop pointer to the text below. CSS triangle made with
|
||||
borders: transparent sides + violet top create a down-arrow
|
||||
that visually connects the pill to its highlighted passage. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-3 top-full size-0 border-x-[5px] border-x-transparent border-t-[6px] border-t-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function pathOf(sourceURL: string): string {
|
||||
const q = sourceURL.indexOf("?");
|
||||
return q < 0 ? sourceURL : sourceURL.slice(0, q);
|
||||
}
|
||||
|
||||
function hlOf(sourceURL: string): string | null {
|
||||
const q = sourceURL.indexOf("?");
|
||||
if (q < 0) return null;
|
||||
const params = new URLSearchParams(sourceURL.slice(q + 1));
|
||||
return params.get("hl");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostActionsFooter — the icon-button row at the bottom of a post card.
|
||||
*
|
||||
* Left cluster: Like · Comment · Reshare
|
||||
* Right cluster: Save to Collection (folder-plus)
|
||||
*
|
||||
* Like / Comment / Reshare are placeholders for now — they fire a
|
||||
* "coming soon" toast on click. Save to Collection mounts the existing
|
||||
* CollectionsDrawer in `existingPostId` mode so the post is attached
|
||||
* directly without creating a duplicate.
|
||||
*
|
||||
* Auth gating is handled per-action via `useAuthGate` — anonymous users
|
||||
* see the in-place auth modal before the underlying action runs.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FolderPlus, Heart, MessageCircle, Repeat2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
||||
|
||||
export function PostActionsFooter({ postID }: { postID: string }) {
|
||||
const { requireAuth } = useAuthGate();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
||||
|
||||
async function withAuth(fn: () => void | Promise<void>) {
|
||||
if (!(await requireAuth())) return;
|
||||
await fn();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ActionButton
|
||||
label="Like"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Likes are coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<Heart className="size-5" />}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Comment"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Comments are coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<MessageCircle className="size-5" />}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Reshare"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Resharing is coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<Repeat2 className="size-5" />}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="Save to collection"
|
||||
onClick={() => withAuth(() => setCollectionsOpen(true))}
|
||||
icon={<FolderPlus className="size-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollectionsDrawer
|
||||
open={collectionsOpen}
|
||||
onClose={() => setCollectionsOpen(false)}
|
||||
isMobile={isMobile}
|
||||
existingPostId={postID}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Helpers shared by PostGridCard and PostScrollCard.
|
||||
*
|
||||
* • postSourceHref — synthesizes `?hl=` if the stored URL lacks one
|
||||
* • themeGradientStyle — picks one of four palette-aware gradients
|
||||
* deterministically from a seed (post id) so a
|
||||
* feed of image-less posts looks varied without
|
||||
* shifting between renders.
|
||||
*/
|
||||
|
||||
import type { Post } from "@/lib/social";
|
||||
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
|
||||
|
||||
export function postSourceHref(post: Post): string {
|
||||
return hasHighlight(post.source_url) || !post.caption
|
||||
? post.source_url
|
||||
: appendHighlight(post.source_url, post.caption);
|
||||
}
|
||||
|
||||
// Quote-card gradients.
|
||||
//
|
||||
// Four CSS custom properties (`--quote-gradient-1` … 4) are defined per
|
||||
// palette in `app/globals.css`. Each palette ships its own hand-tuned
|
||||
// gradient set so the cards swap colour with the active theme:
|
||||
//
|
||||
// • Aged Parchment → mahogany binding + temple gold
|
||||
// • Tulasī Garden → forest depths + amber edge
|
||||
// • Yamunā Mist → river depths + dusk ochre
|
||||
// • Ancient Manuscript → midnight leather + saffron
|
||||
// • Nīlamaṇi → Kṛṣṇa's sapphire hour (deep blues)
|
||||
// • Vraja Twilight → deep wine + rose dusk
|
||||
//
|
||||
// All variants are dark-leaning so `text-white` reads cleanly. The card
|
||||
// picks one of the four deterministically from `post.id`.
|
||||
const GRADIENT_VARS = [
|
||||
"var(--quote-gradient-1)",
|
||||
"var(--quote-gradient-2)",
|
||||
"var(--quote-gradient-3)",
|
||||
"var(--quote-gradient-4)",
|
||||
];
|
||||
|
||||
/** Returns an inline-style object that applies the chosen gradient. The
|
||||
* style approach (vs a className) lets us reference CSS custom properties
|
||||
* that vary per palette — Tailwind can't generate utility classes for
|
||||
* values that change at runtime via `:root` selectors. */
|
||||
export function themeGradientStyle(seed: string): React.CSSProperties {
|
||||
// Cheap, stable hash — same seed always picks the same gradient.
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return { backgroundImage: GRADIENT_VARS[h % GRADIENT_VARS.length] };
|
||||
}
|
||||
|
||||
/** Flat background for post cards without an image: pure white on every
|
||||
* light palette, palette-specific darker contrast on dark palettes.
|
||||
* Defined as `--quote-card-bg` per `:root` / `.dark` block in
|
||||
* globals.css. Combine with `text-foreground` for legibility — both
|
||||
* sides of the theme switch reverse together. */
|
||||
export function quoteCardBgStyle(): React.CSSProperties {
|
||||
return { backgroundColor: "var(--quote-card-bg)" };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Variable quote sizing
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A pithy two-word quote and a full paragraph need very different
|
||||
// treatments inside a fixed-size card. We pick a tier from the caption's
|
||||
// character count:
|
||||
//
|
||||
// tier 1 (very short) — biggest font, dead-centre, no truncation needed
|
||||
// tier 2 (short) — large font, centred, ~5-line clamp
|
||||
// tier 3 (medium) — medium font, centred, ~6/7-line clamp
|
||||
// tier 4 (long) — smallest font, anchored bottom-LEFT and left-
|
||||
// aligned (a centred wall of small text would
|
||||
// cover the entire card and look claustrophobic;
|
||||
// bottom-left mimics the image variant's caption
|
||||
// placement and breathes better).
|
||||
//
|
||||
// Two scales: "grid" for the small square card, "scroll" for the larger
|
||||
// Instagram-style card.
|
||||
|
||||
export type QuoteSize = {
|
||||
/** Tailwind font-size classes, e.g. "text-xl sm:text-2xl" */
|
||||
fontClass: string;
|
||||
/** Tailwind line-clamp class limiting overflow */
|
||||
clampClass: string;
|
||||
/** Where to anchor the text block inside its container */
|
||||
alignment: "center" | "bottom-left";
|
||||
};
|
||||
|
||||
export function quoteSize(text: string, variant: "grid" | "scroll"): QuoteSize {
|
||||
const len = (text ?? "").trim().length;
|
||||
if (variant === "grid") {
|
||||
if (len <= 50)
|
||||
return {
|
||||
fontClass: "text-xl sm:text-2xl",
|
||||
clampClass: "line-clamp-3",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 150)
|
||||
return {
|
||||
fontClass: "text-base sm:text-lg",
|
||||
clampClass: "line-clamp-5",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 300)
|
||||
return {
|
||||
fontClass: "text-sm sm:text-base",
|
||||
clampClass: "line-clamp-[7]",
|
||||
alignment: "center",
|
||||
};
|
||||
return {
|
||||
fontClass: "text-xs sm:text-sm",
|
||||
clampClass: "line-clamp-[9]",
|
||||
alignment: "bottom-left",
|
||||
};
|
||||
}
|
||||
// scroll — bigger card, larger fonts at every tier, higher thresholds
|
||||
// so paragraph-length quotes still get centred at a comfortable size.
|
||||
if (len <= 60)
|
||||
return {
|
||||
fontClass: "text-3xl sm:text-4xl",
|
||||
clampClass: "line-clamp-3",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 180)
|
||||
return {
|
||||
fontClass: "text-2xl sm:text-3xl",
|
||||
clampClass: "line-clamp-5",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 380)
|
||||
return {
|
||||
fontClass: "text-lg sm:text-xl",
|
||||
clampClass: "line-clamp-[8]",
|
||||
alignment: "center",
|
||||
};
|
||||
return {
|
||||
fontClass: "text-base sm:text-lg",
|
||||
clampClass: "line-clamp-[10]",
|
||||
alignment: "bottom-left",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostCard — renders one post in the public feed and the /u/me feed.
|
||||
*
|
||||
* • 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
|
||||
*
|
||||
* 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
|
||||
* verbatim; otherwise we synthesise one from the post's caption so older
|
||||
* posts (or imported data) still scroll to + highlight the text on click.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Trash2, 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";
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
onDelete,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const sourceHref =
|
||||
hasHighlight(post.source_url) || !post.caption
|
||||
? post.source_url
|
||||
: appendHighlight(post.source_url, post.caption);
|
||||
|
||||
return (
|
||||
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
|
||||
{post.images && post.images.length > 0 ? (
|
||||
<div
|
||||
className={`mt-4 grid gap-2 ${
|
||||
post.images.length === 1
|
||||
? "grid-cols-1"
|
||||
: post.images.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{post.images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 30vw, 200px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={sourceHref}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="mt-3 pt-2 border-t border-border">
|
||||
<PostActionsFooter postID={post.id} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostGridCard — square card used in the 2-per-row grid view.
|
||||
*
|
||||
* • With image: first attached image fills the card; a bottom-up dark
|
||||
* gradient makes the caption legible without dropping the image's
|
||||
* atmosphere.
|
||||
* • Without image: a palette-aware Tailwind gradient stands in; the
|
||||
* caption sits as a serif quote bottom-left in the foreground colour.
|
||||
*
|
||||
* `onDelete` shows a small X in the corner for owner views. Used both for
|
||||
* actual post deletion (on /u/me) and for removing from a collection
|
||||
* (on /u/me/collections/[id]); the parent decides which.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ImageIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { postSourceHref, quoteCardBgStyle, quoteSize } from "./post-card-shared";
|
||||
import { UpdateBackgroundModal } from "./update-background-modal";
|
||||
|
||||
export function PostGridCard({
|
||||
post,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
href: hrefProp,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
/** Owner-only: shown when defined so the user can swap/remove the
|
||||
* card's background image. */
|
||||
onUpdated?: (updated: Post) => void;
|
||||
/** Override the click target. Defaults to the source-passage URL.
|
||||
* Profile grids pass a `/u/<handle>/posts#post-<id>` anchor so the
|
||||
* thumbnail jumps to the Instagram-style scroll feed instead of the
|
||||
* knowledgebase page. */
|
||||
href?: string;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [bgModal, setBgModal] = useState(false);
|
||||
const firstImage = post.images?.[0]?.url;
|
||||
const href = hrefProp ?? postSourceHref(post);
|
||||
const size = quoteSize(post.caption, "grid");
|
||||
// Image variant always anchors text bottom-left over the image's dark
|
||||
// overlay. No-image variant lets `size.alignment` decide — short quotes
|
||||
// sit centred so the gradient frames them; long quotes fall back to
|
||||
// bottom-left so they don't cover the whole card.
|
||||
const bottomLeft = !!firstImage || size.alignment === "bottom-left";
|
||||
|
||||
return (
|
||||
<div className="relative group select-none">
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10",
|
||||
"hover:ring-foreground/25 hover:shadow-md transition-all relative",
|
||||
)}
|
||||
>
|
||||
{firstImage ? (
|
||||
<>
|
||||
<Image
|
||||
src={firstImage}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, 320px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{/* Bottom-up gradient for caption legibility — keeps the image
|
||||
visible up top while text sits over solid darkness below. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col p-4 sm:p-5",
|
||||
// Text color: white on the dark image overlay, theme
|
||||
// foreground on the flat quote-card background.
|
||||
firstImage ? "text-white" : "text-foreground",
|
||||
bottomLeft
|
||||
? "justify-end items-start text-left"
|
||||
: "justify-center items-center text-center",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] leading-snug break-words",
|
||||
size.fontClass,
|
||||
size.clampClass,
|
||||
// Drop-shadow only helps when text sits on imagery; on the
|
||||
// flat bg it just blurs the type.
|
||||
firstImage && "drop-shadow-sm",
|
||||
// Centred quotes need a soft horizontal cap so a long line
|
||||
// doesn't kiss the card edge — looks better with margins.
|
||||
!bottomLeft ? "max-w-[90%]" : "w-full",
|
||||
)}
|
||||
>
|
||||
“{post.caption}”
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[0.65rem] uppercase tracking-[0.2em] font-medium truncate",
|
||||
firstImage ? "text-white/80" : "text-muted-foreground",
|
||||
bottomLeft ? "w-full" : "max-w-[90%]",
|
||||
)}
|
||||
>
|
||||
{displaySourceLabel(post.source_url, post.source_title)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onUpdated || onDelete ? (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
{onUpdated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgModal(true)}
|
||||
aria-label="Update background"
|
||||
title="Update background"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(post.id)}
|
||||
aria-label="Remove"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onUpdated ? (
|
||||
<UpdateBackgroundModal
|
||||
open={bgModal}
|
||||
onClose={() => setBgModal(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostScrollCard — Instagram-style post used on the scroll feed.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ┌───────────────────────────────────────┐
|
||||
* │ ◯ name @handle ⋮ │ ← author header (TBD)
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ │
|
||||
* │ image carousel │ ← square, swipeable
|
||||
* │ (or quote) │ (no-image variant
|
||||
* │ │ fills with gradient
|
||||
* │ • ○ ○ ○ │ panel + serif quote)
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ "Quoted passage shown as caption…" │ ← truncates with "more"
|
||||
* │ ŚRĪMAD-BHĀGAVATAM — CANTO 4, CH 5 │ ← citation
|
||||
* └───────────────────────────────────────┘
|
||||
*
|
||||
* • Multi-image carousel uses native CSS scroll-snap with dot indicators
|
||||
* below; the same primitive CollectionScrollCard uses for posts.
|
||||
* • Long captions clamp to 3 lines until the user expands via "more".
|
||||
* • Owner action cluster (Update background / Delete) sits in the
|
||||
* top-right of the media area, fading in on hover (desktop) and
|
||||
* always visible on touch (mobile).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ImageIcon, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post, PostImage } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
||||
import { PostActionsFooter } from "./post-actions-footer";
|
||||
import { UpdateBackgroundModal } from "./update-background-modal";
|
||||
|
||||
export function PostScrollCard({
|
||||
post,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdated?: (updated: Post) => void;
|
||||
}) {
|
||||
const images = post.images ?? [];
|
||||
const hasImages = images.length > 0;
|
||||
const sourceHref = postSourceHref(post);
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [bgModal, setBgModal] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const ownerCluster =
|
||||
onUpdated || onDelete ? (
|
||||
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5">
|
||||
{onUpdated ? (
|
||||
<OwnerButton
|
||||
label="Update background"
|
||||
onClick={() => setBgModal(true)}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<OwnerButton
|
||||
label="Remove"
|
||||
onClick={() => onDelete(post.id)}
|
||||
icon={<Trash2 className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${post.id}`}
|
||||
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
|
||||
>
|
||||
{/* Author header */}
|
||||
<header className="flex items-center gap-3 px-4 py-3">
|
||||
<AuthorAvatar
|
||||
src={post.author_avatar_url ?? null}
|
||||
name={post.author_name ?? "Unknown"}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={
|
||||
post.author_handle ? `/u/${post.author_handle}` : "#"
|
||||
}
|
||||
className="block text-sm font-semibold text-foreground truncate hover:underline"
|
||||
>
|
||||
{post.author_name || "Unknown"}
|
||||
</Link>
|
||||
{post.author_handle ? (
|
||||
<span className="block text-xs text-muted-foreground truncate">
|
||||
@{post.author_handle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Media — image carousel or gradient-with-quote */}
|
||||
<div className="relative">
|
||||
{hasImages ? (
|
||||
<ImageCarousel images={images} />
|
||||
) : (
|
||||
<QuotePanel post={post} />
|
||||
)}
|
||||
{ownerCluster}
|
||||
</div>
|
||||
|
||||
{/* Caption + citation — reference sits directly under the media
|
||||
so it's read first, with the action row below it. */}
|
||||
<div className="px-4 pt-4 pb-3 space-y-2">
|
||||
{hasImages ? (
|
||||
// When images carry the visual, the caption sits as text below.
|
||||
<Caption
|
||||
text={post.caption}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((v) => !v)}
|
||||
/>
|
||||
) : null}
|
||||
<Link
|
||||
href={sourceHref}
|
||||
className="block max-w-full truncate text-[0.7rem] uppercase tracking-[0.2em] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{displaySourceLabel(post.source_url, post.source_title)} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="pt-1 pb-2 border-t border-border">
|
||||
<PostActionsFooter postID={post.id} />
|
||||
</div>
|
||||
|
||||
{onUpdated ? (
|
||||
<UpdateBackgroundModal
|
||||
open={bgModal}
|
||||
onClose={() => setBgModal(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pieces ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ImageCarousel({ images }: { images: PostImage[] }) {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||
const el = e.currentTarget;
|
||||
const w = el.offsetWidth;
|
||||
if (w === 0) return;
|
||||
setActive(Math.round(el.scrollLeft / w));
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<div className="relative aspect-square bg-muted">
|
||||
<Image
|
||||
src={images[0].url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"flex overflow-x-auto snap-x snap-mandatory",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${i + 1} of ${images.length}`}
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
||||
{images.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-1.5 rounded-full transition-all",
|
||||
i === active ? "bg-white" : "bg-white/50",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotePanel({ post }: { post: Post }) {
|
||||
// No-image post: the entire square is a flat palette-aware surface
|
||||
// (pure white on light themes, palette-specific dark on dark themes)
|
||||
// with the quote in serif inside. Text uses --foreground so it inverts
|
||||
// automatically with the theme.
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-square overflow-hidden select-none"
|
||||
style={quoteCardBgStyle()}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center p-8 sm:p-10 text-center text-foreground">
|
||||
<p className="font-[family-name:var(--font-title)] text-xl sm:text-2xl leading-relaxed max-w-md line-clamp-[8] break-words">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Caption({
|
||||
text,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
text: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
// Show 3 lines collapsed. The "more" toggle appears only when the text
|
||||
// is long enough to warrant it (a cheap char-length check beats a
|
||||
// measure-and-compare pass for our purposes — captions over ~180 chars
|
||||
// will always overflow 3 lines at the rendered font size).
|
||||
const longEnough = text.length > 180;
|
||||
return (
|
||||
<div className="text-sm text-foreground leading-relaxed">
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] break-words",
|
||||
!expanded && longEnough && "line-clamp-3",
|
||||
)}
|
||||
>
|
||||
“{text}”
|
||||
</p>
|
||||
{longEnough ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="mt-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? "Show less" : "More"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
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">
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnerButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-9 rounded-full inline-flex items-center justify-center",
|
||||
"bg-black/40 text-white hover:bg-black/60 transition-colors backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostViews — grid-only renderer for post lists.
|
||||
*
|
||||
* Renders a 3-up square grid (Instagram pattern). The previous
|
||||
* Grid/Scroll tab toggle is gone — the scroll feed lives at
|
||||
* /u/<handle>/posts and is reached by clicking a grid thumbnail
|
||||
* (which sets `?hl=` and a hash anchor).
|
||||
*
|
||||
* Callers pass `hrefFor(post)` if they want each thumbnail to link to a
|
||||
* specific URL (e.g. the scroll feed). When omitted, each card defaults
|
||||
* to its source-passage URL.
|
||||
*/
|
||||
|
||||
import type { Post } from "@/lib/social";
|
||||
import { PostGridCard } from "./post-grid-card";
|
||||
|
||||
export function PostViews({
|
||||
posts,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
emptyMessage,
|
||||
hrefFor,
|
||||
}: {
|
||||
posts: Post[];
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdated?: (updated: Post) => void;
|
||||
emptyMessage?: string;
|
||||
hrefFor?: (post: Post) => string;
|
||||
}) {
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emptyMessage ?? "Nothing here yet."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
{posts.map((p) => (
|
||||
<PostGridCard
|
||||
key={p.id}
|
||||
post={p}
|
||||
onDelete={onDelete}
|
||||
onUpdated={onUpdated}
|
||||
href={hrefFor?.(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UpdateBackgroundModal — swap or remove the image behind a post card.
|
||||
*
|
||||
* Two actions:
|
||||
* • Upload Image — pick a file → signed-URL upload → PUT to BFF
|
||||
* → returns the refreshed post.
|
||||
* • Remove Background — PUT empty image set → post falls back to the
|
||||
* palette-aware quote-card gradient.
|
||||
*
|
||||
* Renders as a centred modal on desktop and a bottom drawer on mobile,
|
||||
* mirroring the pattern used by AuthModal / ConfirmModal so the UX stays
|
||||
* consistent across the app.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ImageIcon, Loader2, Trash2, Upload, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { posts, SocialError, type Post } from "@/lib/social";
|
||||
|
||||
type Busy = "upload" | "remove" | null;
|
||||
|
||||
export function UpdateBackgroundModal({
|
||||
open,
|
||||
onClose,
|
||||
post,
|
||||
isMobile,
|
||||
onUpdated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
post: Post;
|
||||
isMobile: boolean;
|
||||
/** Fires with the refreshed post after a successful upload or removal. */
|
||||
onUpdated?: (updated: Post) => void;
|
||||
}) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const hasImage = (post.images?.length ?? 0) > 0;
|
||||
|
||||
// Body-scroll lock + Esc-to-close — desktop modal only. The drawer
|
||||
// handles both internally.
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && busy === null) onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, isMobile, busy, onClose]);
|
||||
|
||||
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
if (!file) return;
|
||||
setBusy("upload");
|
||||
try {
|
||||
const url = await posts.uploadImage(file);
|
||||
const updated = await posts.replaceImages(post.id, [url]);
|
||||
toast.success("Background updated");
|
||||
onUpdated?.(updated);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Could not update background.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
setBusy("remove");
|
||||
try {
|
||||
const updated = await posts.replaceImages(post.id, []);
|
||||
toast.success("Background removed");
|
||||
onUpdated?.(updated);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Could not remove background.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const body = (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={onUpload}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={busy !== null}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-accent transition-colors text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0">
|
||||
{busy === "upload" ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{hasImage ? "Replace Image" : "Upload Image"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
JPEG or PNG, recommended ≥ 1080×1080
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={busy !== null}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-destructive/5 transition-colors text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex items-center justify-center size-10 rounded-lg bg-destructive/10 text-destructive shrink-0">
|
||||
{busy === "remove" ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
Remove Background
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Falls back to a theme-coloured gradient
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(o) => !o && busy === null && onClose()}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Update Background</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4">{body}</div>
|
||||
<div className="p-4 pt-0">
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
className="w-full py-2.5 rounded-lg border border-border text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: centred modal with portal + backdrop, same pattern as
|
||||
// ConfirmModal/AuthModal so behaviour stays consistent.
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={() => busy === null && onClose()}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-bg-title"
|
||||
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
|
||||
>
|
||||
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
|
||||
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
|
||||
<span
|
||||
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<ImageIcon className="size-5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id="update-bg-title"
|
||||
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
|
||||
>
|
||||
Update Background
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
Pick an image to set as the card's background, or remove the
|
||||
current one.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => busy === null && onClose()}
|
||||
aria-label="Close"
|
||||
disabled={busy !== null}
|
||||
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user