From 54bdbbc0e94ab897e307afb136fd00770dadbe12 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 17:27:54 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E7=82=B9=E5=87=BB=20logo=20?= =?UTF-8?q?=E5=9C=A8=E9=A6=96=E9=A1=B5=E6=97=B6=20scroll=20to=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layouts/PublicLayout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 76b0edb..1df5de9 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -343,6 +343,7 @@ export function PublicLayout() { to="/" className="flex h-8 shrink-0 items-center gap-2 rounded-sm text-[20px] font-black leading-5 tracking-tight text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]" aria-label={t("brand")} + onClick={(e) => { if (isHome) { e.preventDefault(); window.scrollTo({ top: 0, behavior: "smooth" }); } }} > {t("brand")} @@ -412,6 +413,7 @@ export function PublicLayout() { { if (isHome) { e.preventDefault(); window.scrollTo({ top: 0, behavior: "smooth" }); } }} > From db5da8a841c13eb673b7cc35d30f404e0b2059f6 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 17:42:37 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=85=88=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20thumbUrl=20=E4=BD=9C=E4=B8=BA=20PDF=20=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E5=A4=A7=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/post.ts | 1 + src/utils/postResourceAdapter.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/post.ts b/src/types/post.ts index 170d1d6..8ac3418 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -30,6 +30,7 @@ export type Attachment = { height?: number; durationSec?: number; posterUrl?: string; + thumbUrl?: string; thumbnailUrl?: string; }; diff --git a/src/utils/postResourceAdapter.ts b/src/utils/postResourceAdapter.ts index 0a3086a..fec65b3 100644 --- a/src/utils/postResourceAdapter.ts +++ b/src/utils/postResourceAdapter.ts @@ -26,7 +26,7 @@ function coverFor(att: Attachment | undefined) { if (att.kind === "image" || att.mime.startsWith("image/")) { return att.thumbnailUrl || att.url; } - return att.posterUrl || att.thumbnailUrl || ""; + return att.posterUrl || att.thumbUrl || att.thumbnailUrl || ""; } export function postToResource( From b22ecc22ad7ab34cba6f0ecb5c10995d2e37efc8 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 17:49:58 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E3=80=8C?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E6=9B=B4=E6=96=B0=E3=80=8D=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E7=BB=9F=E4=B8=80=E4=B8=BA=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E7=AB=96=E5=90=91=E6=B0=94=E6=B3=A1=E6=B5=81(responsive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/Home/index.tsx | 107 ++++++--------------------------------- 1 file changed, 15 insertions(+), 92 deletions(-) diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 01fda97..5019140 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -4,10 +4,6 @@ import { useEffect, useRef, useState } from "react"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api"; import { CategoryIcon } from "../../components/CategoryIcon"; import { FigmaBanner } from "../../components/FigmaBanner"; -import { - ComingSoonLatestUpdateRow, - LatestUpdateRow, -} from "../../components/LatestUpdateRow"; import { PopularRankList } from "../../components/PopularRankList"; import { RecommendedCard } from "../../components/RecommendedCard"; import { SectionHeader } from "../../components/SectionHeader"; @@ -49,17 +45,14 @@ export function Home() { const { hash } = useLocation(); const [cats, setCats] = useState([]); const [rec, setRec] = useState([]); - const [latest, setLatest] = useState([]); const [latestPosts, setLatestPosts] = useState([]); const [popular, setPopular] = useState([]); const [popularPosts, setPopularPosts] = useState([]); const [err, setErr] = useState(null); const recRowRef = useRef(null); - const latestRowRef = useRef(null); const categoryRowRef = useRef(null); const [activeCategoryPage, setActiveCategoryPage] = useState(0); const [canScrollRec, setCanScrollRec] = useState(false); - const [canScrollLatest, setCanScrollLatest] = useState(false); const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 }); useEffect(() => { @@ -88,9 +81,6 @@ export function Home() { ); const latestItems = itemsOrEmpty(l.items); setLatestPosts(latestItems); - setLatest( - latestItems.map((post) => postToResource(post, lang, categoryItems)), - ); const popularItems = itemsOrEmpty(p.items); setPopularPosts(popularItems); setPopular( @@ -139,9 +129,6 @@ export function Home() { }; }, [lang]); - const iconKeyForResource = (r: PostBackedResource) => - cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder"; - const figmaOrderedCategories = [...cats].sort( (a, b) => figmaCategoryRank(a) - figmaCategoryRank(b), ); @@ -208,31 +195,6 @@ export function Home() { recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" }); }; - useEffect(() => { - const row = latestRowRef.current; - if (!row) { - setCanScrollLatest(false); - return; - } - - const update = () => { - setCanScrollLatest(row.scrollWidth > row.clientWidth + 1); - }; - - update(); - const resizeObserver = new ResizeObserver(update); - resizeObserver.observe(row); - row.addEventListener("scroll", update, { passive: true }); - return () => { - resizeObserver.disconnect(); - row.removeEventListener("scroll", update); - }; - }, [latest.length]); - - const scrollLatest = (dir: 1 | -1) => { - latestRowRef.current?.scrollBy({ left: dir * 360, behavior: "smooth" }); - }; - useEffect(() => { if (!hash) return; const id = hash.slice(1); @@ -243,9 +205,8 @@ export function Home() { }); }); return () => window.cancelAnimationFrame(frame); - }, [hash, cats.length, rec.length, latest.length, popular.length]); + }, [hash, cats.length, rec.length, latestPosts.length, popular.length]); - const latestPlaceholderCount = Math.max(0, 5 - latest.length); const hasPopular = popular.length > 0 || popularPosts.length > 0; const recommendedDotCount = rec.length; const activeRecommendedDot = @@ -446,59 +407,21 @@ export function Home() {
-
- -
-
- {latestPosts.slice(0, 5).map((post) => ( - - ))} -
-
-
- {latest.map((r) => ( -
- -
- ))} - {Array.from({ length: latestPlaceholderCount }).map( - (_, index) => ( -
- -
- ), - )} +
+
+ +
+
+ {latestPosts.slice(0, 5).map((post, index) => ( + + + + ))}
- {canScrollLatest ? ( - <> - - - - ) : null}
From 1f8772f645faea4efdf5da95a8c9f610b7b922c4 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 17:50:53 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E7=9B=B8=E5=86=8C=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E7=9B=B4=E6=8E=A5=E8=BF=9B=E5=A4=A7=E5=9B=BE=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=99=A8,=E7=A7=BB=E9=99=A4=E3=80=8C=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=9B=BE=E7=89=87=E3=80=8D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AlbumBubble: 点 +N 直接打开全屏查看器(从该图开始),删除中间的选择列表弹窗 - ImageLightbox: 底部加可滑动缩略图条(当前高亮+自动滚动定位),顶栏加下载按钮 - 下载按钮保证此前藏在 +N 列表里的图片仍可下载 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../messageStream/bubbles/AlbumBubble.tsx | 165 +------------ .../messageStream/overlays/ImageLightbox.tsx | 226 ++++++++++++++---- 2 files changed, 188 insertions(+), 203 deletions(-) diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 40a22b5..7b91ccf 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -1,17 +1,10 @@ -import { LoaderCircle, X } from "lucide-react"; -import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { useI18n } from "../../../i18n"; -import type { Attachment, Post } from "../../../types/post"; +import type { Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { useLightbox } from "../overlays/ImageLightbox"; 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; @@ -26,135 +19,9 @@ function albumItemClass(index: number, count: number) { return ""; } -function ImageListDownloadButton({ - postId, - attachment, -}: { - postId: string; - attachment: Attachment; -}) { - const { t } = useI18n(); - const { showToast } = useToast(); - const [isDownloading, setIsDownloading] = useState(false); - - const handleDownload = async () => { - if (isDownloading) return; - setIsDownloading(true); - try { - await downloadAttachment(postId, attachment.id, attachment.filename); - showToast(t("downloadOk")); - } catch { - showToast(t("downloadFail"), "error"); - } finally { - setIsDownloading(false); - } - }; - - return ( - - ); -} - -function ImageListDialog({ - postId, - images, - onClose, - onPick, -}: { - postId: string; - images: Attachment[]; - onClose: () => void; - onPick: (index: number) => void; -}) { - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - return createPortal( -
-
e.stopPropagation()} - > -
-
选择图片
- -
-
- {images.map((image, index) => ( -
- - -
- ))} -
-
-
, - document.body, - ); -} - export function AlbumBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); const { lang } = useI18n(); - const [listOpen, setListOpen] = useState(false); const images = post.attachments; const text = postDisplayText(post, lang); const visible = images.slice(0, MAX_VISIBLE); @@ -176,21 +43,22 @@ export function AlbumBubble({ post }: { post: Post }) { > @@ -206,17 +74,6 @@ export function AlbumBubble({ post }: { post: Post }) { {autolink(text)} ) : null} - {listOpen ? ( - setListOpen(false)} - onPick={(index) => { - setListOpen(false); - openLightbox(images, index, text, post.id); - }} - /> - ) : null} ); } diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index eaacad2..62241e7 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -8,9 +8,14 @@ import { type PropsWithChildren, } from "react"; import { createPortal } from "react-dom"; -import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react"; import type { Attachment } from "../../../types/post"; +import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; +import { useI18n } from "../../../i18n"; +import { useToast } from "../../Toast"; +import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; +import { downloadAttachment } from "../utils/downloadFile"; type LightboxState = { images: Attachment[]; @@ -65,6 +70,7 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { images={state.images} startIndex={state.index} caption={state.caption} + postId={state.postId} onClose={closeLightbox} /> ) : null} @@ -72,15 +78,118 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { ); } +function LightboxDownloadButton({ + postId, + attachment, +}: { + postId: string; + attachment: Attachment; +}) { + const { t } = useI18n(); + const { showToast } = useToast(); + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + if (isDownloading) return; + setIsDownloading(true); + try { + await downloadAttachment(postId, attachment.id, attachment.filename); + showToast(t("downloadOk")); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } + }; + + return ( + + ); +} + +function Filmstrip({ + images, + index, + onSelect, +}: { + images: Attachment[]; + index: number; + onSelect: (i: number) => void; +}) { + const activeRef = useRef(null); + + useEffect(() => { + activeRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + }, [index]); + + return ( +
e.stopPropagation()} + > +
+ {images.map((image, i) => { + const active = i === index; + return ( + + ); + })} +
+
+ ); +} + function LightboxView({ images, startIndex, caption: captionText, + postId, onClose, }: { images: Attachment[]; startIndex: number; caption?: string; + postId?: string; onClose: () => void; }) { const [index, setIndex] = useState(startIndex); @@ -118,6 +227,7 @@ function LightboxView({ const current = images[index]; const caption = captionText?.trim(); const showCaption = !!caption && isCaptionVisible; + const hasMany = images.length > 1; if (!current) return null; return createPortal( @@ -127,51 +237,64 @@ function LightboxView({ role="dialog" aria-modal="true" > - - - {images.length > 1 ? ( - <> +
+ {hasMany ? ( + + {index + 1} / {images.length} + + ) : null} +
+
+ {postId ? ( + + ) : null} - -
- {index + 1} / {images.length} -
- - ) : null} +
+ + + {/* Image stage */} +
+ {hasMany ? ( + <> + + + + ) : null} -
{ e.stopPropagation(); if (caption) setIsCaptionVisible((visible) => !visible); @@ -192,21 +315,26 @@ function LightboxView({ {current.filename}
+ + {showCaption ? ( +
e.stopPropagation()} + > +
+ {autolink(caption)} +
+
+ ) : null}
- {showCaption ? ( -
e.stopPropagation()} - > -
- {autolink(caption)} -
-
+ {/* Bottom thumbnail filmstrip */} + {hasMany ? ( + ) : null}
, document.body, From 4a097bad9d3fc6761aa4cc555c97fd9c7886db31 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 17:58:30 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E7=83=AD=E9=97=A8=E6=A6=9C?= =?UTF-8?q?=E5=8D=95=E7=A7=BB=E9=99=A4=E9=A2=84=E8=A7=88=E6=8C=89=E9=92=AE?= =?UTF-8?q?,=E4=BB=85=E4=BF=9D=E7=95=99=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/PopularRankList.tsx | 32 +----------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index afd8598..6d0f503 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -1,7 +1,6 @@ import { Archive, Download, - Eye, File, FileText, Image as ImageIcon, @@ -20,9 +19,7 @@ import { resourceTypeLabel } from "../resourceTypeLabels"; import { cleanCategoryDisplayName } from "../utils/categoryDisplay"; import { formatDateYmd } from "../utils/format"; import { postToResource } from "../utils/postResourceAdapter"; -import type { Attachment, Post } from "../types/post"; -import { useLightbox } from "./messageStream/overlays/ImageLightbox"; -import { useVideoPlayer } from "./messageStream/overlays/VideoPlayer"; +import type { Post } from "../types/post"; import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { useToast } from "./Toast"; @@ -94,8 +91,6 @@ function PopularRankRow({ }) { const { t, lang } = useI18n(); const navigate = useNavigate(); - const { openLightbox } = useLightbox(); - const { openVideo } = useVideoPlayer(); const { showToast } = useToast(); const [isDownloading, setIsDownloading] = useState(false); const [coverFailed, setCoverFailed] = useState(false); @@ -104,22 +99,6 @@ function PopularRankRow({ const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : ""; const isTop3 = index < MEDALS.length; - const first: Attachment | undefined = post.attachments[0]; - const imageAttachments = post.attachments.filter((a) => a.kind === "image"); - - const handlePreview = () => { - if (first?.kind === "video") { - openVideo(first); - return; - } - if (imageAttachments.length > 0) { - openLightbox(imageAttachments, 0, r.title, post.id); - return; - } - // Documents / links have no inline overlay — fall through to the detail page. - navigate(`/resource/${post.id}`); - }; - const handleDownload = async () => { if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; setIsDownloading(true); @@ -185,15 +164,6 @@ function PopularRankRow({
- {r.isDownloadable ? ( diff --git a/vite.config.ts b/vite.config.ts index 685ae82..0f556f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,17 @@ import react from "@vitejs/plugin-react"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const apiProxyTarget = env.DEV_API_PROXY_TARGET || "http://127.0.0.1:8080"; + // Uploaded assets (thumbnails etc.) are served from the site root, NOT under + // the API's /apnew prefix. Proxy /uploads to the origin root so relative + // thumbnailUrls like "/uploads/thumb52-….jpg" resolve to a real image in dev + // instead of falling through to the SPA's index.html. + const uploadsProxyTarget = (() => { + try { + return new URL(apiProxyTarget).origin; + } catch { + return apiProxyTarget; + } + })(); return { plugins: [react()], @@ -26,7 +37,7 @@ export default defineConfig(({ mode }) => { rewrite: (path) => path.replace(/^\/apnew/, ""), }, "/api": { target: apiProxyTarget, changeOrigin: true }, - "/uploads": { target: apiProxyTarget, changeOrigin: true }, + "/uploads": { target: uploadsProxyTarget, changeOrigin: true }, }, }, }; From 042635528a98481a6ba03849d829bbc0278a6969 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 18:11:30 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E7=81=AF=E7=AE=B1=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B8=8B=E8=BD=BD=E3=80=81=E5=BA=95=E9=83=A8=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE=E5=B1=85=E4=B8=AD=E3=80=81=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E5=90=8E=E4=BF=9D=E7=95=99=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../messageStream/overlays/ImageLightbox.tsx | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index 639f786..c41657d 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -147,34 +147,36 @@ function Filmstrip({ className="shrink-0 bg-gradient-to-t from-black/90 to-transparent pb-[max(env(safe-area-inset-bottom),0.75rem)] pt-3" onClick={(e) => e.stopPropagation()} > -
- {images.map((image, i) => { - const active = i === index; - return ( - - ); - })} +
+
+ {images.map((image, i) => { + const active = i === index; + return ( + + ); + })} +
); @@ -213,13 +215,21 @@ function LightboxView({ if (e.key === "ArrowRight") goNext(); }; window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [goPrev, goNext, onClose]); + + // Lock background scroll while the lightbox is open, then restore the exact + // scroll position on close — otherwise dismissing the overlay leaves the page + // scrolled back to the top instead of where the user was reading. + useEffect(() => { + const scrollY = window.scrollY; const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { - window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; + window.scrollTo(0, scrollY); }; - }, [goPrev, goNext, onClose]); + }, []); useEffect(() => { setIsCaptionVisible(true);