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 ? ( - ); -} - -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,23 @@ export function AlbumBubble({ post }: { post: Post }) { > @@ -206,17 +75,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..c41657d 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,121 @@ 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); @@ -103,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); @@ -118,6 +238,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 +248,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 +326,26 @@ function LightboxView({ {current.filename}
+ + {showCaption ? ( +
e.stopPropagation()} + > +
+ {autolink(caption)} +
+
+ ) : null}
- {showCaption ? ( -
e.stopPropagation()} - > -
- {autolink(caption)} -
-
+ {/* Bottom thumbnail filmstrip */} + {hasMany ? ( + ) : null}
, document.body, 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" }); } }} > 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}
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( 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 }, }, }, };