diff --git a/public/assets/ark-library/media/png/academy-materials.png b/public/assets/ark-library/media/png/academy-materials.png new file mode 100644 index 0000000..e3edaa3 Binary files /dev/null and b/public/assets/ark-library/media/png/academy-materials.png differ diff --git a/public/assets/ark-library/media/png/academy-video.png b/public/assets/ark-library/media/png/academy-video.png new file mode 100644 index 0000000..afbf4f9 Binary files /dev/null and b/public/assets/ark-library/media/png/academy-video.png differ diff --git a/src/components/CategoryIcon.tsx b/src/components/CategoryIcon.tsx index 0eddbda..c2d317f 100644 --- a/src/components/CategoryIcon.tsx +++ b/src/components/CategoryIcon.tsx @@ -15,7 +15,7 @@ import { Play, type LucideIcon, } from "lucide-react"; -import { categorySvgUrlForSlug } from "../lib/categorySvgSlug"; +import { categoryAssetUrlForSlug } from "../lib/categorySvgSlug"; const map: Record = { folder: Folder, @@ -40,15 +40,15 @@ export function CategoryIcon({ className, }: { iconKey: string; - /** When set, prefer branded SVG from `public/assets/ark-library/media/svg/`. */ + /** When set, prefer branded asset from `public/assets/ark-library/media/`. */ categorySlug?: string; className?: string; }) { - const svgUrl = categorySlug ? categorySvgUrlForSlug(categorySlug) : null; - if (svgUrl) { + const assetUrl = categorySlug ? categoryAssetUrlForSlug(categorySlug) : null; + if (assetUrl) { return ( ` triggers the stream's scroll-to-post without a full + * reload. External URLs return null and fall back to a plain ``. + */ +function internalPath(linkUrl: string): string | null { + try { + if (linkUrl.startsWith("//")) return null; + if (linkUrl.startsWith("/")) return linkUrl; + const url = new URL(linkUrl, window.location.origin); + if (url.origin !== window.location.origin) return null; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +} + function toSlides(items: BannerApiItem[]): BannerSlide[] { return [...items] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) @@ -59,8 +79,12 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] { export function FigmaBanner() { const { lang } = useI18n(); + const navigate = useNavigate(); const [slides, setSlides] = useState([]); const scrollerRef = useRef(null); + // Set when a mouse drag just moved the carousel, so the synthetic `click` + // that follows pointerup doesn't navigate the slide's link. + const suppressClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); const [autoplayPaused, setAutoplayPaused] = useState(false); const [publicMenuOpen, setPublicMenuOpen] = useState(false); @@ -177,7 +201,9 @@ export function FigmaBanner() { startScrollLeft: scroller.scrollLeft, moved: false, }; - scroller.setPointerCapture(event.pointerId); + // Don't capture the pointer yet: capturing on press makes the browser + // dispatch the following `click` to the scroller instead of the slide's + // , swallowing the link. Capture only once a real drag begins (below). pauseAutoplay(); }; @@ -189,6 +215,7 @@ export function FigmaBanner() { const dx = event.clientX - drag.startX; if (!drag.moved && Math.abs(dx) > 4) { drag.moved = true; + scroller.setPointerCapture(event.pointerId); scroller.style.scrollSnapType = "none"; } if (drag.moved) { @@ -206,6 +233,11 @@ export function FigmaBanner() { scroller.releasePointerCapture(event.pointerId); } if (drag.moved) { + // Swallow the click that the browser fires right after a drag-release. + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); const width = scroller.clientWidth || 1; const nearest = Math.round(scroller.scrollLeft / width); const clamped = Math.max(0, Math.min(slides.length - 1, nearest)); @@ -214,6 +246,27 @@ export function FigmaBanner() { } }; + const handleSlideClick = ( + event: ReactMouseEvent, + linkUrl: string, + ) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + event.preventDefault(); + return; + } + // Let modified clicks (new tab / window) and external links use default + // browser behavior; route same-app links through the SPA so the stream's + // `?post=` scroll-to-post runs without a full page reload. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + const path = internalPath(linkUrl); + if (!path) return; + event.preventDefault(); + navigate(path); + }; + if (slides.length === 0) return null; const pagination = hasMultiple ? ( @@ -287,7 +340,12 @@ export function FigmaBanner() { aria-label={`${index + 1} / ${slides.length}`} > {slide.linkUrl ? ( - + handleSlideClick(event, slide.linkUrl!)} + > {image} ) : ( diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 0e9e938..3b96164 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -119,7 +119,9 @@ function PopularRankRow({
) : null} + {showSaveHint ? ( +
+
e.stopPropagation()} + > + +
+ ☝️ +
+
+ {t("longPressImageSave")} +
+
+
+ ) : null} +
{ @@ -279,11 +312,12 @@ function LightboxView({ touchStartX.current = null; }} > + {/* No select-none / touch-callout:none here so iOS Safari's native + long-press menu ("Save in Photos") works on the full-size image. */} {current.filename}
diff --git a/src/components/messageStream/utils/filenameDisplay.ts b/src/components/messageStream/utils/filenameDisplay.ts index e9da4ff..8a58b95 100644 --- a/src/components/messageStream/utils/filenameDisplay.ts +++ b/src/components/messageStream/utils/filenameDisplay.ts @@ -51,7 +51,7 @@ export function middleEllipsisFilename( return `${base.slice(0, headLength)}${ellipsis}${base.slice(-tailLength)}${ext}`; } -function splitFilename(filename: string): { base: string; ext: string } { +export function splitFilename(filename: string): { base: string; ext: string } { const dotIndex = filename.lastIndexOf("."); if (!hasFileExtension(filename)) return { base: filename, ext: "" }; return { diff --git a/src/i18n.tsx b/src/i18n.tsx index ca3b7d7..9f5994b 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -21,7 +21,7 @@ const zhDict: Dict = { popular: "热门资料", search: "搜索", searchPlaceholder: "搜索资料...", - searchPanelPlaceholder: "搜索 PPT、影片、海报、公告、教程、文...", + searchPanelPlaceholder: "搜索资料...", searchNow: "立即搜索资料", searchSubmit: "搜索", cancel: "取消", @@ -45,6 +45,7 @@ const zhDict: Dict = { downloading: "下载中…", downloadOk: "下载完成", downloadFail: "下载失败,请重试", + longPressImageSave: "长按图片保存到相册", showMore: "展开全部", showLess: "收起全部", share: "分享", @@ -148,7 +149,7 @@ const enDict: Dict = { popular: "Popular", search: "Search", searchPlaceholder: "Search resources...", - searchPanelPlaceholder: "Search PPT, videos, posters, news, guides...", + searchPanelPlaceholder: "Search assets...", searchNow: "Search now", searchSubmit: "Search", cancel: "Cancel", @@ -173,6 +174,7 @@ const enDict: Dict = { downloading: "Downloading…", downloadOk: "Download complete", downloadFail: "Download failed, please retry", + longPressImageSave: "Long-press image to save", showMore: "Show all", showLess: "Show less", share: "Share", diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 7d70149..748fbfd 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -413,7 +413,7 @@ export function PublicLayout() { return (
-
+
{/* Logo → home; page-name text → scroll to top of the current page. */} @@ -715,7 +715,7 @@ export function PublicLayout() { -