feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s

Recommended cards already routed to /browse#post-<id>, but the stream had
no logic to scroll to the target bubble — and the post might not be paged
in yet. MessageStream now resolves the #post-<id> hash, auto-loads more
pages until the bubble renders, scrolls to it, and gives it a brief gold
highlight. Bubbles get scroll-mt so they clear the sticky header.

Also adds a global floating back-to-top button (BackToTop) mounted in
PublicLayout, shown after scrolling past 400px.

Bundles related staging UI work already present in the working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TerryM
2026-05-29 11:50:27 +08:00
parent 8e36894851
commit 88a25b6ad4
27 changed files with 748 additions and 139 deletions

View File

@@ -5,6 +5,7 @@ import { useI18n } from "../../i18n";
import type { Attachment } from "../../types/post";
import { downloadAttachment } from "./utils/downloadFile";
import { formatBytes } from "./utils/formatBytes";
import { useToast } from "../Toast";
type AttachmentDownloadPillProps = {
postId: string;
@@ -20,15 +21,21 @@ export function AttachmentDownloadPill({
className = "absolute left-2 top-2",
}: AttachmentDownloadPillProps) {
const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = (e: MouseEvent<HTMLButtonElement>) => {
const handleDownload = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (isDownloading) return;
setIsDownloading(true);
void downloadAttachment(postId, attachment.id, attachment.filename)
.finally(() => setIsDownloading(false))
.catch(() => {});
try {
await downloadAttachment(postId, attachment.id, attachment.filename);
showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (

View File

@@ -35,7 +35,7 @@ export function MessageBubble({ post }: { post: Post }) {
return (
<div
id={`post-${post.id}`}
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
className="mx-auto w-full max-w-[358px] scroll-mt-20 md:max-w-[680px] md:scroll-mt-24 lg:max-w-[900px] xl:max-w-[1120px]"
>
<article
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${

View File

@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { useLocation, useSearchParams } from "react-router-dom";
import { postJSON } from "../../api";
import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post";
import { Reveal } from "../../motion";
import { Skeleton } from "../Skeleton";
import { FilterChips } from "./FilterChips";
import { MessageBubble } from "./MessageBubble";
import { useGroupedByDay } from "./hooks/useGroupedByDay";
@@ -15,6 +17,7 @@ export type MessageStreamProps = {
export function MessageStream({ scope }: MessageStreamProps) {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const { hash } = useLocation();
const type = sp.get("type") || "all";
const q = (sp.get("q") || "").trim();
@@ -65,6 +68,38 @@ export function MessageStream({ scope }: MessageStreamProps) {
return () => io.disconnect();
}, [loadMore]);
// When arriving with a `#post-<id>` hash (e.g. from a recommended card),
// scroll to that bubble — loading more pages until it shows up — then give
// it a brief highlight so the user can see where they landed.
const targetPostId =
hash.startsWith("#post-") ? hash.slice("#post-".length) : "";
const handledTargetRef = useRef<string>("");
useEffect(() => {
handledTargetRef.current = "";
}, [targetPostId]);
useEffect(() => {
if (!targetPostId || handledTargetRef.current === targetPostId) return;
const el = document.getElementById(`post-${targetPostId}`);
if (el) {
handledTargetRef.current = targetPostId;
const frame = window.requestAnimationFrame(() => {
el.scrollIntoView({ block: "start", behavior: "smooth" });
el.classList.add("ark-bubble-highlight");
window.setTimeout(
() => el.classList.remove("ark-bubble-highlight"),
2000,
);
});
return () => window.cancelAnimationFrame(frame);
}
// Not loaded yet — keep paging until it appears or the stream is exhausted.
if (hasMore && !isLoading) loadMore();
}, [targetPostId, items, hasMore, isLoading, loadMore]);
const updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp);
if (!value || value === "all") n.delete(key);
@@ -72,41 +107,64 @@ export function MessageStream({ scope }: MessageStreamProps) {
setSp(n, { replace: true });
};
const isInitialLoad = isLoading && items.length === 0;
return (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-3">
{group.items.map((post) => (
<MessageBubble key={post.id} post={post} />
{isInitialLoad ? (
<>
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
>
<Skeleton
className={`rounded-2xl ${
i % 3 === 0 ? "h-[220px]" : "h-[80px]"
}`}
/>
</div>
))}
</>
) : (
<>
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-3">
{group.items.map((post, index) => (
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
<MessageBubble post={post} />
</Reveal>
))}
</div>
))}
</div>
))}
{!isLoading && !error && items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
) : null}
{!isLoading && !error && items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
) : null}
{error ? (
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<span className="break-all">{error}</span>
<button
type="button"
onClick={() => reset()}
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
>
{retryLabel}
</button>
</div>
) : null}
{error ? (
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<span className="break-all">{error}</span>
<button
type="button"
onClick={() => reset()}
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
>
{retryLabel}
</button>
</div>
) : null}
{isLoading ? (
<div className="py-4 text-center text-xs text-neutral-500"></div>
) : null}
{isLoading ? (
<div className="py-4 text-center text-xs text-neutral-500"></div>
) : null}
</>
)}
<div ref={sentinelRef} aria-hidden className="h-1" />
</div>

View File

@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
import { useToast } from "../../Toast";
const MAX_VISIBLE = 4;
@@ -32,14 +33,20 @@ function ImageListDownloadButton({
attachment: Attachment;
}) {
const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => {
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
void downloadAttachment(postId, attachment.id, attachment.filename)
.finally(() => setIsDownloading(false))
.catch(() => {});
try {
await downloadAttachment(postId, attachment.id, attachment.filename);
showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (

View File

@@ -11,19 +11,26 @@ import {
} from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
import { useToast } from "../../Toast";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n();
const { showToast } = useToast();
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => {
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
void downloadAttachment(postId, att.id, displayFilename)
.finally(() => setIsDownloading(false))
.catch(() => {});
try {
await downloadAttachment(postId, att.id, displayFilename);
showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (

View File

@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
import { useToast } from "../../Toast";
const MAX_VISIBLE = 4;
@@ -173,14 +174,20 @@ function AttachmentListDownloadButton({
attachment: Attachment;
}) {
const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => {
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
void downloadAttachment(postId, attachment.id, attachment.filename)
.finally(() => setIsDownloading(false))
.catch(() => {});
try {
await downloadAttachment(postId, attachment.id, attachment.filename);
showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (