feat: add media save guide

This commit is contained in:
TerryM
2026-06-01 23:00:28 +08:00
parent 7b48f9780c
commit e096d59fa6
16 changed files with 437 additions and 102 deletions

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { MotionProvider } from "./motion"; import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast"; import { ToastProvider } from "./components/Toast";
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { LocalizedHomePage } from "./pages/LocalizedHome"; import { LocalizedHomePage } from "./pages/LocalizedHome";
import { Browse } from "./pages/Browse"; import { Browse } from "./pages/Browse";
@@ -27,87 +28,89 @@ export default function App() {
<I18nProvider> <I18nProvider>
<MotionProvider> <MotionProvider>
<ToastProvider> <ToastProvider>
<AdminRouterModeProvider value="absolute"> <SaveToAlbumGuideProvider>
<ImageLightboxProvider> <AdminRouterModeProvider value="absolute">
<VideoPlayerProvider> <ImageLightboxProvider>
<PageTitleProvider> <VideoPlayerProvider>
<BrowserRouter> <PageTitleProvider>
<ScrollToTop /> <BrowserRouter>
<Routes> <ScrollToTop />
<Route element={<PublicLayout />}> <Routes>
{/* English (root, no prefix) */} <Route element={<PublicLayout />}>
<Route {/* English (root, no prefix) */}
path="/" <Route
element={<LocalizedHomePage targetLang="en" />} path="/"
/> element={<LocalizedHomePage targetLang="en" />}
<Route path="/browse" element={<Browse />} /> />
<Route <Route path="/browse" element={<Browse />} />
path="/categories" <Route
element={<CategoriesPage />} path="/categories"
/> element={<CategoriesPage />}
<Route />
path="/official-recommendations" <Route
element={<OfficialRecommendationsPage />} path="/official-recommendations"
/> element={<OfficialRecommendationsPage />}
<Route />
path="/category/:slug" <Route
element={<CategoryPage />} path="/category/:slug"
/> element={<CategoryPage />}
<Route path="/search" element={<SearchPage />} /> />
<Route <Route path="/search" element={<SearchPage />} />
path="/resource/:id" <Route
element={<PostRedirect />} path="/resource/:id"
/> element={<PostRedirect />}
<Route path="/favorites" element={<Favorites />} /> />
<Route path="/favorites" element={<Favorites />} />
{/* Each non-English language gets its own nested tree. */} {/* Each non-English language gets its own nested tree. */}
{localizedHomeRoutes.map((route) => ( {localizedHomeRoutes.map((route) => (
<Route key={route.path} path={route.path}> <Route key={route.path} path={route.path}>
<Route <Route
index index
element={ element={
<LocalizedHomePage targetLang={route.lang} /> <LocalizedHomePage targetLang={route.lang} />
} }
/> />
<Route path="browse" element={<Browse />} /> <Route path="browse" element={<Browse />} />
<Route <Route
path="categories" path="categories"
element={<CategoriesPage />} element={<CategoriesPage />}
/> />
<Route <Route
path="official-recommendations" path="official-recommendations"
element={<OfficialRecommendationsPage />} element={<OfficialRecommendationsPage />}
/> />
<Route <Route
path="category/:slug" path="category/:slug"
element={<CategoryPage />} element={<CategoryPage />}
/> />
<Route path="search" element={<SearchPage />} /> <Route path="search" element={<SearchPage />} />
<Route <Route
path="resource/:id" path="resource/:id"
element={<PostRedirect />} element={<PostRedirect />}
/> />
<Route path="favorites" element={<Favorites />} /> <Route path="favorites" element={<Favorites />} />
</Route> </Route>
))} ))}
</Route> </Route>
{adminEnabled ? ( {adminEnabled ? (
AdminRouteTree() AdminRouteTree()
) : ( ) : (
<Route <Route
path={`${adminUiPrefix}/*`} path={`${adminUiPrefix}/*`}
element={<Navigate to="/" replace />} element={<Navigate to="/" replace />}
/> />
)} )}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</PageTitleProvider> </PageTitleProvider>
</VideoPlayerProvider> </VideoPlayerProvider>
</ImageLightboxProvider> </ImageLightboxProvider>
</AdminRouterModeProvider> </AdminRouterModeProvider>
</SaveToAlbumGuideProvider>
</ToastProvider> </ToastProvider>
</MotionProvider> </MotionProvider>
</I18nProvider> </I18nProvider>

View File

@@ -22,6 +22,7 @@ import { formatDateYmd } from "../utils/format";
import { postToResource } from "../utils/postResourceAdapter"; import { postToResource } from "../utils/postResourceAdapter";
import type { Post } from "../types/post"; import type { Post } from "../types/post";
import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { downloadAttachment } from "./messageStream/utils/downloadFile";
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
const MEDALS = ["🥇", "🥈", "🥉"]; const MEDALS = ["🥇", "🥈", "🥉"];
@@ -94,6 +95,7 @@ function PopularRankRow({
const navigate = useNavigate(); const navigate = useNavigate();
const lp = useLocalizedPath(); const lp = useLocalizedPath();
const { showToast } = useToast(); const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const [coverFailed, setCoverFailed] = useState(false); const [coverFailed, setCoverFailed] = useState(false);
@@ -110,6 +112,8 @@ function PopularRankRow({
r.downloadAttachmentId, r.downloadAttachmentId,
r.title, r.title,
); );
const mediaKind = mediaSaveKindFromType(r.type);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch { } catch {
showToast(t("downloadFail"), "error"); showToast(t("downloadFail"), "error");
} finally { } finally {

View File

@@ -13,6 +13,7 @@ import {
downloadAttachment, downloadAttachment,
downloadFile, downloadFile,
} from "./messageStream/utils/downloadFile"; } from "./messageStream/utils/downloadFile";
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
function isPlaceholderAsset(path: string | undefined | null) { function isPlaceholderAsset(path: string | undefined | null) {
@@ -52,6 +53,7 @@ export function RecommendedCard({
const { t } = useI18n(); const { t } = useI18n();
const lp = useLocalizedPath(); const lp = useLocalizedPath();
const { showToast } = useToast(); const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const figmaCover = const figmaCover =
officialRecommendationCoverFallbacks[ officialRecommendationCoverFallbacks[
@@ -87,6 +89,8 @@ export function RecommendedCard({
} else { } else {
await downloadFile(dl, displayTitle); await downloadFile(dl, displayTitle);
} }
const mediaKind = mediaSaveKindFromType(r.type);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch { } catch {
showToast(t("downloadFail"), "error"); showToast(t("downloadFail"), "error");
} finally { } finally {

View File

@@ -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<SaveToAlbumGuideContextValue | null>(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<SaveToAlbumMediaKind | null>(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 (
<SaveToAlbumGuideContext.Provider value={value}>
{children}
{mediaKind
? createPortal(
<div
className="fixed inset-0 z-[130] flex items-center justify-center bg-black/70 px-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="save-album-guide-title"
onClick={close}
>
<div
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[#1c1c21] text-neutral-100 shadow-2xl shadow-black/70"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ark-gold/80">
{mediaKind === "video"
? t("saveAlbumGuideVideoLabel")
: t("saveAlbumGuideImageLabel")}
</p>
<h2
id="save-album-guide-title"
className="mt-1 text-lg font-semibold text-white"
>
{t("saveAlbumGuideTitle")}
</h2>
</div>
<button
type="button"
onClick={close}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
aria-label={t("cancel")}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-5 py-5">
<p className="text-sm leading-6 text-neutral-300">
{t("saveAlbumGuideIntro")}
</p>
<ol className="mt-4 space-y-3">
{platformStepKeys(platform).map((key, index) => (
<li key={key} className="flex gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
{index + 1}
</span>
<span className="pt-0.5 text-sm leading-6 text-neutral-100">
{t(key)}
</span>
</li>
))}
</ol>
<button
type="button"
onClick={close}
className="mt-5 flex h-11 w-full items-center justify-center rounded-full bg-ark-gold px-4 text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1c1c21]"
>
{t("confirm")}
</button>
</div>
</div>
</div>,
document.body,
)
: null}
</SaveToAlbumGuideContext.Provider>
);
}

View File

@@ -3,8 +3,12 @@ import { DownloadCloudIcon } from "../icons/DownloadCloudIcon";
import { useState, type MouseEvent } from "react"; import { useState, type MouseEvent } from "react";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import type { Attachment } from "../../types/post"; import type { Attachment } from "../../types/post";
import { downloadAttachment } from "./utils/downloadFile"; import { downloadAttachment, pauseActiveVideos } from "./utils/downloadFile";
import { formatBytes } from "./utils/formatBytes"; import { formatBytes } from "./utils/formatBytes";
import {
mediaSaveKindFromAttachment,
useSaveToAlbumGuide,
} from "../SaveToAlbumGuide";
import { useToast } from "../Toast"; import { useToast } from "../Toast";
type AttachmentDownloadPillProps = { type AttachmentDownloadPillProps = {
@@ -40,14 +44,18 @@ export function AttachmentDownloadPill({
}: AttachmentDownloadPillProps) { }: AttachmentDownloadPillProps) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast(); const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async (e: MouseEvent<HTMLButtonElement>) => { const handleDownload = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
if (isDownloading) return; if (isDownloading) return;
pauseActiveVideos();
setIsDownloading(true); setIsDownloading(true);
try { try {
await downloadAttachment(postId, attachment.id, attachment.filename); await downloadAttachment(postId, attachment.id, attachment.filename);
const mediaKind = mediaSaveKindFromAttachment(attachment);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch { } catch {
showToast(t("downloadFail"), "error"); showToast(t("downloadFail"), "error");
} finally { } finally {
@@ -84,6 +92,7 @@ export function AttachmentDownloadPill({
return ( return (
<button <button
type="button" type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={handleDownload} onClick={handleDownload}
disabled={isDownloading} disabled={isDownloading}
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${fontCls} text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`} className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${fontCls} text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}

View File

@@ -9,11 +9,16 @@ import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText"; import { CollapsibleText } from "../CollapsibleText";
import {
mediaSaveKindFromAttachment,
useSaveToAlbumGuide,
} from "../../SaveToAlbumGuide";
import { useToast } from "../../Toast"; import { useToast } from "../../Toast";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast(); const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime); const displayFilename = filenameWithExtension(att.filename, att.mime);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
@@ -24,6 +29,8 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
setIsDownloading(true); setIsDownloading(true);
try { try {
await downloadAttachment(postId, att.id, displayFilename); await downloadAttachment(postId, att.id, displayFilename);
const mediaKind = mediaSaveKindFromAttachment(att);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch { } catch {
showToast(t("downloadFail"), "error"); showToast(t("downloadFail"), "error");
} finally { } finally {

View File

@@ -13,13 +13,14 @@ import {
import { useVideoPlayer } from "../overlays/VideoPlayer"; import { useVideoPlayer } from "../overlays/VideoPlayer";
import { autolink } from "../utils/autolink"; import { autolink } from "../utils/autolink";
import { CollapsibleText } from "../CollapsibleText"; import { CollapsibleText } from "../CollapsibleText";
import { downloadAttachment } from "../utils/downloadFile"; import { downloadAttachment, pauseActiveVideos } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { import {
videoMetadataPreviewSource, videoMetadataPreviewSource,
videoPreviewSource, videoPreviewSource,
} from "../utils/videoPreviewSource"; } from "../utils/videoPreviewSource";
import { useSaveToAlbumGuide } from "../../SaveToAlbumGuide";
import { useToast } from "../../Toast"; import { useToast } from "../../Toast";
const MAX_VISIBLE = 4; const MAX_VISIBLE = 4;
@@ -167,13 +168,16 @@ function AttachmentListDownloadButton({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast(); const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => { const handleDownload = async () => {
if (isDownloading) return; if (isDownloading) return;
pauseActiveVideos();
setIsDownloading(true); setIsDownloading(true);
try { try {
await downloadAttachment(postId, attachment.id, attachment.filename); await downloadAttachment(postId, attachment.id, attachment.filename);
showSaveToAlbumGuide("video");
} catch { } catch {
showToast(t("downloadFail"), "error"); showToast(t("downloadFail"), "error");
} finally { } finally {
@@ -184,6 +188,7 @@ function AttachmentListDownloadButton({
return ( return (
<button <button
type="button" type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDownload(); handleDownload();

View File

@@ -1,5 +1,11 @@
import { assetUrl } from "../../../api"; import { assetUrl } from "../../../api";
export function pauseActiveVideos() {
document.querySelectorAll("video").forEach((video) => {
video.pause();
});
}
export function attachmentDownloadUrl(postId: string, attachmentId: string) { export function attachmentDownloadUrl(postId: string, attachmentId: string) {
return assetUrl( return assetUrl(
`/api/posts/${encodeURIComponent(postId)}/attachments/${encodeURIComponent( `/api/posts/${encodeURIComponent(postId)}/attachments/${encodeURIComponent(

View File

@@ -626,13 +626,6 @@ export function PublicLayout() {
> >
{t("latest")} {t("latest")}
</Link> </Link>
<Link
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link <Link
to={popularHref} to={popularHref}
className={navClassName(na("browsePopular"))} className={navClassName(na("browsePopular"))}
@@ -717,14 +710,6 @@ export function PublicLayout() {
> >
{t("latest")} {t("latest")}
</Link> </Link>
<Link
to={lp("/favorites")}
className={mobileMenuNavClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("favorites")}
</Link>
<Link <Link
to={popularHref} to={popularHref}
className={mobileMenuNavClassName(na("browsePopular"))} className={mobileMenuNavClassName(na("browsePopular"))}
@@ -784,7 +769,7 @@ export function PublicLayout() {
</main> </main>
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden"> <nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
<div className="grid h-[68px] grid-cols-4 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]"> <div className="grid h-[68px] grid-cols-3 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]">
<BottomNavIcon <BottomNavIcon
to={homePath} to={homePath}
label={t("home")} label={t("home")}
@@ -800,12 +785,6 @@ export function PublicLayout() {
!new URLSearchParams(search).get("sort") !new URLSearchParams(search).get("sort")
} }
/> />
<BottomNavIcon
to={lp("/favorites")}
label={t("favorites")}
icon="bookmark"
active={na("favorites")}
/>
<BottomNavIcon <BottomNavIcon
to={popularHref} to={popularHref}
label={t("popular")} label={t("popular")}

View File

@@ -37,6 +37,25 @@ export const enDict: Dict = {
downloadOk: "Download complete", downloadOk: "Download complete",
downloadFail: "Download failed, please retry", downloadFail: "Download failed, please retry",
longPressImageSave: "Long-press image to save", longPressImageSave: "Long-press image to save",
saveAlbumGuideTitle: "Save to album guide",
saveAlbumGuideIntro:
"Your download has started. Follow the steps below to save the file to your phone album.",
saveAlbumGuideImageLabel: "Image",
saveAlbumGuideVideoLabel: "Video",
saveAlbumGuideIosStep1:
"After the download finishes, tap Safaris download icon or open the Downloads folder in the Files app.",
saveAlbumGuideIosStep2: "Open the downloaded image or video.",
saveAlbumGuideIosStep3:
"Tap the share button and choose Save Image or Save Video to add it to Photos.",
saveAlbumGuideAndroidStep1:
"After the download finishes, tap the download notification or open Files / Downloads.",
saveAlbumGuideAndroidStep2: "Open the downloaded image or video.",
saveAlbumGuideAndroidStep3:
"If it does not appear in Gallery automatically, use the menu to save or move it to Photos/Gallery.",
saveAlbumGuideDesktopStep1:
"After the download finishes, open it from your browser downloads list or the Downloads folder.",
saveAlbumGuideDesktopStep2:
"To keep it in a phone album, transfer it to your phone with AirDrop, cable, or cloud drive, then save it there.",
showMore: "Show all", showMore: "Show all",
showLess: "Show less", showLess: "Show less",
share: "Share", share: "Share",

View File

@@ -37,6 +37,25 @@ export const idDict: Dict = {
downloadOk: "Unduhan selesai", downloadOk: "Unduhan selesai",
downloadFail: "Unduhan gagal, silakan coba lagi", downloadFail: "Unduhan gagal, silakan coba lagi",
longPressImageSave: "Tekan lama gambar untuk menyimpan", longPressImageSave: "Tekan lama gambar untuk menyimpan",
saveAlbumGuideTitle: "Save to album guide",
saveAlbumGuideIntro:
"Your download has started. Follow the steps below to save the file to your phone album.",
saveAlbumGuideImageLabel: "Image",
saveAlbumGuideVideoLabel: "Video",
saveAlbumGuideIosStep1:
"After the download finishes, tap Safaris download icon or open the Downloads folder in the Files app.",
saveAlbumGuideIosStep2: "Open the downloaded image or video.",
saveAlbumGuideIosStep3:
"Tap the share button and choose Save Image or Save Video to add it to Photos.",
saveAlbumGuideAndroidStep1:
"After the download finishes, tap the download notification or open Files / Downloads.",
saveAlbumGuideAndroidStep2: "Open the downloaded image or video.",
saveAlbumGuideAndroidStep3:
"If it does not appear in Gallery automatically, use the menu to save or move it to Photos/Gallery.",
saveAlbumGuideDesktopStep1:
"After the download finishes, open it from your browser downloads list or the Downloads folder.",
saveAlbumGuideDesktopStep2:
"To keep it in a phone album, transfer it to your phone with AirDrop, cable, or cloud drive, then save it there.",
showMore: "Lihat semua", showMore: "Lihat semua",
showLess: "Tutup", showLess: "Tutup",
share: "Bagikan", share: "Bagikan",

View File

@@ -37,6 +37,25 @@ export const jaDict: Dict = {
downloadOk: "ダウンロード完了", downloadOk: "ダウンロード完了",
downloadFail: "ダウンロードに失敗しました。再試行してください", downloadFail: "ダウンロードに失敗しました。再試行してください",
longPressImageSave: "画像を長押しして保存", longPressImageSave: "画像を長押しして保存",
saveAlbumGuideTitle: "Save to album guide",
saveAlbumGuideIntro:
"Your download has started. Follow the steps below to save the file to your phone album.",
saveAlbumGuideImageLabel: "Image",
saveAlbumGuideVideoLabel: "Video",
saveAlbumGuideIosStep1:
"After the download finishes, tap Safaris download icon or open the Downloads folder in the Files app.",
saveAlbumGuideIosStep2: "Open the downloaded image or video.",
saveAlbumGuideIosStep3:
"Tap the share button and choose Save Image or Save Video to add it to Photos.",
saveAlbumGuideAndroidStep1:
"After the download finishes, tap the download notification or open Files / Downloads.",
saveAlbumGuideAndroidStep2: "Open the downloaded image or video.",
saveAlbumGuideAndroidStep3:
"If it does not appear in Gallery automatically, use the menu to save or move it to Photos/Gallery.",
saveAlbumGuideDesktopStep1:
"After the download finishes, open it from your browser downloads list or the Downloads folder.",
saveAlbumGuideDesktopStep2:
"To keep it in a phone album, transfer it to your phone with AirDrop, cable, or cloud drive, then save it there.",
showMore: "すべて表示", showMore: "すべて表示",
showLess: "閉じる", showLess: "閉じる",
share: "シェア", share: "シェア",

View File

@@ -36,6 +36,25 @@ export const koDict: Dict = {
downloadOk: "다운로드 완료", downloadOk: "다운로드 완료",
downloadFail: "다운로드 실패, 다시 시도해 주세요", downloadFail: "다운로드 실패, 다시 시도해 주세요",
longPressImageSave: "이미지를 길게 눌러 저장", longPressImageSave: "이미지를 길게 눌러 저장",
saveAlbumGuideTitle: "Save to album guide",
saveAlbumGuideIntro:
"Your download has started. Follow the steps below to save the file to your phone album.",
saveAlbumGuideImageLabel: "Image",
saveAlbumGuideVideoLabel: "Video",
saveAlbumGuideIosStep1:
"After the download finishes, tap Safaris download icon or open the Downloads folder in the Files app.",
saveAlbumGuideIosStep2: "Open the downloaded image or video.",
saveAlbumGuideIosStep3:
"Tap the share button and choose Save Image or Save Video to add it to Photos.",
saveAlbumGuideAndroidStep1:
"After the download finishes, tap the download notification or open Files / Downloads.",
saveAlbumGuideAndroidStep2: "Open the downloaded image or video.",
saveAlbumGuideAndroidStep3:
"If it does not appear in Gallery automatically, use the menu to save or move it to Photos/Gallery.",
saveAlbumGuideDesktopStep1:
"After the download finishes, open it from your browser downloads list or the Downloads folder.",
saveAlbumGuideDesktopStep2:
"To keep it in a phone album, transfer it to your phone with AirDrop, cable, or cloud drive, then save it there.",
showMore: "모두 보기", showMore: "모두 보기",
showLess: "접기", showLess: "접기",
share: "공유", share: "공유",

View File

@@ -37,6 +37,25 @@ export const msDict: Dict = {
downloadOk: "Muat turun selesai", downloadOk: "Muat turun selesai",
downloadFail: "Muat turun gagal, sila cuba lagi", downloadFail: "Muat turun gagal, sila cuba lagi",
longPressImageSave: "Tekan lama imej untuk simpan", longPressImageSave: "Tekan lama imej untuk simpan",
saveAlbumGuideTitle: "Save to album guide",
saveAlbumGuideIntro:
"Your download has started. Follow the steps below to save the file to your phone album.",
saveAlbumGuideImageLabel: "Image",
saveAlbumGuideVideoLabel: "Video",
saveAlbumGuideIosStep1:
"After the download finishes, tap Safaris download icon or open the Downloads folder in the Files app.",
saveAlbumGuideIosStep2: "Open the downloaded image or video.",
saveAlbumGuideIosStep3:
"Tap the share button and choose Save Image or Save Video to add it to Photos.",
saveAlbumGuideAndroidStep1:
"After the download finishes, tap the download notification or open Files / Downloads.",
saveAlbumGuideAndroidStep2: "Open the downloaded image or video.",
saveAlbumGuideAndroidStep3:
"If it does not appear in Gallery automatically, use the menu to save or move it to Photos/Gallery.",
saveAlbumGuideDesktopStep1:
"After the download finishes, open it from your browser downloads list or the Downloads folder.",
saveAlbumGuideDesktopStep2:
"To keep it in a phone album, transfer it to your phone with AirDrop, cable, or cloud drive, then save it there.",
showMore: "Lihat semua", showMore: "Lihat semua",
showLess: "Tutup", showLess: "Tutup",
share: "Kongsi", share: "Kongsi",

View File

@@ -37,6 +37,25 @@ export const viDict: Dict = {
downloadOk: "Tải xuống hoàn tất", downloadOk: "Tải xuống hoàn tất",
downloadFail: "Tải xuống thất bại, vui lòng thử lại", downloadFail: "Tải xuống thất bại, vui lòng thử lại",
longPressImageSave: "Nhấn giữ ảnh để lưu", longPressImageSave: "Nhấn giữ ảnh để lưu",
saveAlbumGuideTitle: "Save to album guide",
saveAlbumGuideIntro:
"Your download has started. Follow the steps below to save the file to your phone album.",
saveAlbumGuideImageLabel: "Image",
saveAlbumGuideVideoLabel: "Video",
saveAlbumGuideIosStep1:
"After the download finishes, tap Safaris download icon or open the Downloads folder in the Files app.",
saveAlbumGuideIosStep2: "Open the downloaded image or video.",
saveAlbumGuideIosStep3:
"Tap the share button and choose Save Image or Save Video to add it to Photos.",
saveAlbumGuideAndroidStep1:
"After the download finishes, tap the download notification or open Files / Downloads.",
saveAlbumGuideAndroidStep2: "Open the downloaded image or video.",
saveAlbumGuideAndroidStep3:
"If it does not appear in Gallery automatically, use the menu to save or move it to Photos/Gallery.",
saveAlbumGuideDesktopStep1:
"After the download finishes, open it from your browser downloads list or the Downloads folder.",
saveAlbumGuideDesktopStep2:
"To keep it in a phone album, transfer it to your phone with AirDrop, cable, or cloud drive, then save it there.",
showMore: "Xem tất cả", showMore: "Xem tất cả",
showLess: "Thu gọn", showLess: "Thu gọn",
share: "Chia sẻ", share: "Chia sẻ",

View File

@@ -36,6 +36,25 @@ export const zhDict: Dict = {
downloadOk: "下载完成", downloadOk: "下载完成",
downloadFail: "下载失败,请重试", downloadFail: "下载失败,请重试",
longPressImageSave: "长按图片保存到相册", longPressImageSave: "长按图片保存到相册",
saveAlbumGuideTitle: "保存到相册指南",
saveAlbumGuideIntro:
"下载已经开始。不同手机系统需要按下面步骤把文件保存到相册。",
saveAlbumGuideImageLabel: "图片",
saveAlbumGuideVideoLabel: "视频",
saveAlbumGuideIosStep1:
"下载完成后,点 Safari 地址栏旁边的下载图标或打开「文件」App 的「下载」文件夹。",
saveAlbumGuideIosStep2: "打开刚下载的图片或视频。",
saveAlbumGuideIosStep3:
"点左下角分享按钮,选择「储存图像」或「储存视频」,即可保存到照片。",
saveAlbumGuideAndroidStep1:
"下载完成后,点系统下载通知,或打开「文件管理 / Downloads」文件夹。",
saveAlbumGuideAndroidStep2: "打开刚下载的图片或视频。",
saveAlbumGuideAndroidStep3:
"如果没有自动出现在相册,请点更多菜单,选择保存到相册/图库。",
saveAlbumGuideDesktopStep1:
"下载完成后,在浏览器的下载列表或电脑 Downloads 文件夹中打开文件。",
saveAlbumGuideDesktopStep2:
"如果需要同步到手机相册,请通过 AirDrop、数据线或云盘传到手机后保存。",
showMore: "展开全部", showMore: "展开全部",
showLess: "收起全部", showLess: "收起全部",
share: "分享", share: "分享",