From e096d59fa61d1fb3241fa610d6dbd44529175fbe Mon Sep 17 00:00:00 2001 From: TerryM Date: Mon, 1 Jun 2026 23:00:28 +0800 Subject: [PATCH] feat: add media save guide --- src/App.tsx | 159 +++++++-------- src/components/PopularRankList.tsx | 4 + src/components/RecommendedCard.tsx | 4 + src/components/SaveToAlbumGuide.tsx | 185 ++++++++++++++++++ .../messageStream/AttachmentDownloadPill.tsx | 11 +- .../messageStream/bubbles/FileDocBubble.tsx | 7 + .../messageStream/bubbles/VideoBubble.tsx | 7 +- .../messageStream/utils/downloadFile.ts | 6 + src/layouts/PublicLayout.tsx | 23 +-- src/locales/en.ts | 19 ++ src/locales/id.ts | 19 ++ src/locales/ja.ts | 19 ++ src/locales/ko.ts | 19 ++ src/locales/ms.ts | 19 ++ src/locales/vi.ts | 19 ++ src/locales/zh-CN.ts | 19 ++ 16 files changed, 437 insertions(+), 102 deletions(-) create mode 100644 src/components/SaveToAlbumGuide.tsx diff --git a/src/App.tsx b/src/App.tsx index f113a4a..0141b7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; +import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { PublicLayout } from "./layouts/PublicLayout"; import { LocalizedHomePage } from "./pages/LocalizedHome"; import { Browse } from "./pages/Browse"; @@ -27,87 +28,89 @@ export default function App() { - - - - - - - - }> - {/* English (root, no prefix) */} - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } /> + + + + + + + + + }> + {/* English (root, no prefix) */} + } + /> + } /> + } + /> + } + /> + } + /> + } /> + } + /> + } /> - {/* Each non-English language gets its own nested tree. */} - {localizedHomeRoutes.map((route) => ( - - - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } /> - - ))} - + {/* Each non-English language gets its own nested tree. */} + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } /> + } + /> + } /> + + ))} + - {adminEnabled ? ( - AdminRouteTree() - ) : ( - } - /> - )} + {adminEnabled ? ( + AdminRouteTree() + ) : ( + } + /> + )} - } /> - - - - - - + } /> + + + + + + + diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 74a3dc9..2c3b7a1 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -22,6 +22,7 @@ import { formatDateYmd } from "../utils/format"; import { postToResource } from "../utils/postResourceAdapter"; import type { Post } from "../types/post"; import { downloadAttachment } from "./messageStream/utils/downloadFile"; +import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { useToast } from "./Toast"; const MEDALS = ["🥇", "🥈", "🥉"]; @@ -94,6 +95,7 @@ function PopularRankRow({ const navigate = useNavigate(); const lp = useLocalizedPath(); const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); const [isDownloading, setIsDownloading] = useState(false); const [coverFailed, setCoverFailed] = useState(false); @@ -110,6 +112,8 @@ function PopularRankRow({ r.downloadAttachmentId, r.title, ); + const mediaKind = mediaSaveKindFromType(r.type); + if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { showToast(t("downloadFail"), "error"); } finally { diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 549866f..ff21b20 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -13,6 +13,7 @@ import { downloadAttachment, downloadFile, } from "./messageStream/utils/downloadFile"; +import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { useToast } from "./Toast"; function isPlaceholderAsset(path: string | undefined | null) { @@ -52,6 +53,7 @@ export function RecommendedCard({ const { t } = useI18n(); const lp = useLocalizedPath(); const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); const [isDownloading, setIsDownloading] = useState(false); const figmaCover = officialRecommendationCoverFallbacks[ @@ -87,6 +89,8 @@ export function RecommendedCard({ } else { await downloadFile(dl, displayTitle); } + const mediaKind = mediaSaveKindFromType(r.type); + if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { showToast(t("downloadFail"), "error"); } finally { diff --git a/src/components/SaveToAlbumGuide.tsx b/src/components/SaveToAlbumGuide.tsx new file mode 100644 index 0000000..2f09698 --- /dev/null +++ b/src/components/SaveToAlbumGuide.tsx @@ -0,0 +1,185 @@ +import { X } from "lucide-react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "../i18n"; +import type { Attachment } from "../types/post"; + +export type SaveToAlbumMediaKind = "image" | "video"; + +type SaveToAlbumGuideContextValue = { + showSaveToAlbumGuide: (kind: SaveToAlbumMediaKind) => void; +}; + +const SaveToAlbumGuideContext = + createContext(null); + +export function useSaveToAlbumGuide(): SaveToAlbumGuideContextValue { + const ctx = useContext(SaveToAlbumGuideContext); + if (!ctx) { + throw new Error( + "useSaveToAlbumGuide must be used within SaveToAlbumGuideProvider", + ); + } + return ctx; +} + +export function mediaSaveKindFromAttachment( + attachment: Attachment, +): SaveToAlbumMediaKind | null { + if (attachment.kind === "image" || attachment.mime.startsWith("image/")) { + return "image"; + } + if (attachment.kind === "video" || attachment.mime.startsWith("video/")) { + return "video"; + } + return null; +} + +export function mediaSaveKindFromType( + type: string | undefined, +): SaveToAlbumMediaKind | null { + if (type === "image") return "image"; + if (type === "video") return "video"; + return null; +} + +type SavePlatform = "ios" | "android" | "desktop"; + +function detectSavePlatform(): SavePlatform { + if (typeof navigator === "undefined") return "desktop"; + const ua = navigator.userAgent || ""; + if (/android/i.test(ua)) return "android"; + if (/iPad|iPhone|iPod/i.test(ua)) return "ios"; + if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) { + return "ios"; + } + return "desktop"; +} + +function platformStepKeys(platform: SavePlatform): string[] { + if (platform === "ios") { + return [ + "saveAlbumGuideIosStep1", + "saveAlbumGuideIosStep2", + "saveAlbumGuideIosStep3", + ]; + } + if (platform === "android") { + return [ + "saveAlbumGuideAndroidStep1", + "saveAlbumGuideAndroidStep2", + "saveAlbumGuideAndroidStep3", + ]; + } + return ["saveAlbumGuideDesktopStep1", "saveAlbumGuideDesktopStep2"]; +} + +export function SaveToAlbumGuideProvider({ + children, +}: { + children: ReactNode; +}) { + const { t } = useI18n(); + const [mediaKind, setMediaKind] = useState(null); + const platform = useMemo(() => detectSavePlatform(), []); + + const showSaveToAlbumGuide = useCallback((kind: SaveToAlbumMediaKind) => { + setMediaKind(kind); + }, []); + + const close = useCallback(() => setMediaKind(null), []); + + useEffect(() => { + if (!mediaKind) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [close, mediaKind]); + + const value = useMemo( + () => ({ showSaveToAlbumGuide }), + [showSaveToAlbumGuide], + ); + + return ( + + {children} + {mediaKind + ? createPortal( +
+
event.stopPropagation()} + > +
+
+

+ {mediaKind === "video" + ? t("saveAlbumGuideVideoLabel") + : t("saveAlbumGuideImageLabel")} +

+

+ {t("saveAlbumGuideTitle")} +

+
+ +
+ +
+

+ {t("saveAlbumGuideIntro")} +

+
    + {platformStepKeys(platform).map((key, index) => ( +
  1. + + {index + 1} + + + {t(key)} + +
  2. + ))} +
+ +
+
+
, + document.body, + ) + : null} +
+ ); +} diff --git a/src/components/messageStream/AttachmentDownloadPill.tsx b/src/components/messageStream/AttachmentDownloadPill.tsx index cf8481c..6ddbedc 100644 --- a/src/components/messageStream/AttachmentDownloadPill.tsx +++ b/src/components/messageStream/AttachmentDownloadPill.tsx @@ -3,8 +3,12 @@ import { DownloadCloudIcon } from "../icons/DownloadCloudIcon"; import { useState, type MouseEvent } from "react"; import { useI18n } from "../../i18n"; import type { Attachment } from "../../types/post"; -import { downloadAttachment } from "./utils/downloadFile"; +import { downloadAttachment, pauseActiveVideos } from "./utils/downloadFile"; import { formatBytes } from "./utils/formatBytes"; +import { + mediaSaveKindFromAttachment, + useSaveToAlbumGuide, +} from "../SaveToAlbumGuide"; import { useToast } from "../Toast"; type AttachmentDownloadPillProps = { @@ -40,14 +44,18 @@ export function AttachmentDownloadPill({ }: AttachmentDownloadPillProps) { const { t } = useI18n(); const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); const [isDownloading, setIsDownloading] = useState(false); const handleDownload = async (e: MouseEvent) => { e.stopPropagation(); if (isDownloading) return; + pauseActiveVideos(); setIsDownloading(true); try { await downloadAttachment(postId, attachment.id, attachment.filename); + const mediaKind = mediaSaveKindFromAttachment(attachment); + if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { showToast(t("downloadFail"), "error"); } finally { @@ -84,6 +92,7 @@ export function AttachmentDownloadPill({ return (