Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
This commit is contained in:
41
src/App.tsx
41
src/App.tsx
@@ -35,19 +35,11 @@ export default function App() {
|
|||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
|
{/* English (root, no prefix) */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={<LocalizedHomePage targetLang="en" />}
|
element={<LocalizedHomePage targetLang="en" />}
|
||||||
/>
|
/>
|
||||||
{localizedHomeRoutes.map((route) => (
|
|
||||||
<Route
|
|
||||||
key={route.path}
|
|
||||||
path={route.path}
|
|
||||||
element={
|
|
||||||
<LocalizedHomePage targetLang={route.lang} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route
|
<Route
|
||||||
path="/categories"
|
path="/categories"
|
||||||
@@ -67,6 +59,37 @@ export default function App() {
|
|||||||
element={<PostRedirect />}
|
element={<PostRedirect />}
|
||||||
/>
|
/>
|
||||||
<Route path="/favorites" element={<Favorites />} />
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
|
|
||||||
|
{/* Each non-English language gets its own nested tree. */}
|
||||||
|
{localizedHomeRoutes.map((route) => (
|
||||||
|
<Route key={route.path} path={route.path}>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<LocalizedHomePage targetLang={route.lang} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="browse" element={<Browse />} />
|
||||||
|
<Route
|
||||||
|
path="categories"
|
||||||
|
element={<CategoriesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="official-recommendations"
|
||||||
|
element={<OfficialRecommendationsPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="category/:slug"
|
||||||
|
element={<CategoryPage />}
|
||||||
|
/>
|
||||||
|
<Route path="search" element={<SearchPage />} />
|
||||||
|
<Route
|
||||||
|
path="resource/:id"
|
||||||
|
element={<PostRedirect />}
|
||||||
|
/>
|
||||||
|
<Route path="favorites" element={<Favorites />} />
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{adminEnabled ? (
|
{adminEnabled ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
import type { Resource } from "../api";
|
import type { Resource } from "../api";
|
||||||
import { CategoryIcon } from "./CategoryIcon";
|
import { CategoryIcon } from "./CategoryIcon";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
|
|
||||||
@@ -16,10 +17,11 @@ export function LatestUpdateRow({
|
|||||||
iconKey: string;
|
iconKey: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const dateStr = formatDateYmd(r.updatedAt);
|
const dateStr = formatDateYmd(r.updatedAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/resource/${r.id}`} className={LATEST_CARD_CLASS}>
|
<Link to={lp(`/resource/${r.id}`)} className={LATEST_CARD_CLASS}>
|
||||||
<div className="flex shrink-0 items-center justify-center pt-0.5">
|
<div className="flex shrink-0 items-center justify-center pt-0.5">
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconKey={iconKey}
|
iconKey={iconKey}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { assetUrl, type Category } from "../api";
|
import { assetUrl, type Category } from "../api";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||||
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
@@ -91,6 +92,7 @@ function PopularRankRow({
|
|||||||
}) {
|
}) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [coverFailed, setCoverFailed] = useState(false);
|
const [coverFailed, setCoverFailed] = useState(false);
|
||||||
@@ -120,7 +122,9 @@ function PopularRankRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
|
navigate(
|
||||||
|
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
aria-label={r.title}
|
aria-label={r.title}
|
||||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
|||||||
import type { Resource } from "../api";
|
import type { Resource } from "../api";
|
||||||
import { assetUrl } from "../api";
|
import { assetUrl } from "../api";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
|
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
|
||||||
@@ -49,6 +50,7 @@ export function RecommendedCard({
|
|||||||
layout?: "carousel" | "grid";
|
layout?: "carousel" | "grid";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const figmaCover =
|
const figmaCover =
|
||||||
@@ -105,7 +107,7 @@ export function RecommendedCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/resource/${r.id}`}
|
to={lp(`/resource/${r.id}`)}
|
||||||
aria-label={displayTitle}
|
aria-label={displayTitle}
|
||||||
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, type Lang } from "../i18n";
|
import { langQuery, type Lang } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import type { Post, PostListResponse } from "../types/post";
|
import type { Post, PostListResponse } from "../types/post";
|
||||||
import { MessageBubble } from "./messageStream/MessageBubble";
|
import { MessageBubble } from "./messageStream/MessageBubble";
|
||||||
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
|
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
|
||||||
@@ -126,6 +127,7 @@ export function SearchPanel({
|
|||||||
onResultClick,
|
onResultClick,
|
||||||
}: SearchPanelProps) {
|
}: SearchPanelProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const [tags, setTags] = useState<TagItem[]>([]);
|
const [tags, setTags] = useState<TagItem[]>([]);
|
||||||
const [selectedTag, setSelectedTag] = useState("");
|
const [selectedTag, setSelectedTag] = useState("");
|
||||||
const [tagPosts, setTagPosts] = useState<Post[]>([]);
|
const [tagPosts, setTagPosts] = useState<Post[]>([]);
|
||||||
@@ -334,7 +336,7 @@ export function SearchPanel({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={post.id}
|
key={post.id}
|
||||||
to={`/browse?post=${encodeURIComponent(post.id)}`}
|
to={lp(`/browse?post=${encodeURIComponent(post.id)}`)}
|
||||||
onClick={onResultClick}
|
onClick={onResultClick}
|
||||||
className="block rounded-2xl border border-white/10 bg-[#191921] px-4 py-3 transition hover:border-ark-gold/60 hover:bg-[#22232D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
className="block rounded-2xl border border-white/10 bg-[#191921] px-4 py-3 transition hover:border-ark-gold/60 hover:bg-[#22232D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { Attachment } from "../../types/post";
|
import type { Attachment } from "../../types/post";
|
||||||
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
|
||||||
|
import { useVideoPreviewSource } from "./hooks/useVideoPreviewSource";
|
||||||
import { useVideoPlayer } from "./overlays/VideoPlayer";
|
import { useVideoPlayer } from "./overlays/VideoPlayer";
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
@@ -127,6 +128,7 @@ export function MessageInlineVideo({
|
|||||||
const [snapProgress, setSnapProgress] = useState(false);
|
const [snapProgress, setSnapProgress] = useState(false);
|
||||||
|
|
||||||
const t = TOKENS[size];
|
const t = TOKENS[size];
|
||||||
|
const videoSrc = useVideoPreviewSource(attachment);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -270,7 +272,7 @@ export function MessageInlineVideo({
|
|||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={attachment.url}
|
src={videoSrc}
|
||||||
poster={attachment.posterUrl}
|
poster={attachment.posterUrl}
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { useI18n } from "../../../i18n";
|
|||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
|
import {
|
||||||
|
useShouldUseMobilePreview,
|
||||||
|
useVideoPreviewSource,
|
||||||
|
} from "../hooks/useVideoPreviewSource";
|
||||||
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 } from "../utils/downloadFile";
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
import {
|
||||||
|
videoMetadataPreviewSource,
|
||||||
|
videoPreviewSource,
|
||||||
|
} from "../utils/videoPreviewSource";
|
||||||
import { useToast } from "../../Toast";
|
import { useToast } from "../../Toast";
|
||||||
|
|
||||||
const MAX_VISIBLE = 4;
|
const MAX_VISIBLE = 4;
|
||||||
@@ -59,9 +67,8 @@ function VideoAttachmentCard({
|
|||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
||||||
const duration = formatDuration(attachment.durationSec);
|
const duration = formatDuration(attachment.durationSec);
|
||||||
const previewVideoUrl = attachment.url.includes("#")
|
const videoSrc = useVideoPreviewSource(attachment);
|
||||||
? attachment.url
|
const previewVideoUrl = videoMetadataPreviewSource(videoSrc);
|
||||||
: `${attachment.url}#t=0.1`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -208,6 +215,8 @@ function VideoListDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (attachment: Attachment) => void;
|
onPick: (attachment: Attachment) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const useMobilePreview = useShouldUseMobilePreview();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") onClose();
|
if (event.key === "Escape") onClose();
|
||||||
@@ -242,9 +251,9 @@ function VideoListDialog({
|
|||||||
<div className="max-h-[70vh] overflow-y-auto p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="max-h-[70vh] overflow-y-auto p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{videos.map((video, index) => {
|
{videos.map((video, index) => {
|
||||||
const thumb = video.posterUrl ?? video.thumbnailUrl;
|
const thumb = video.posterUrl ?? video.thumbnailUrl;
|
||||||
const previewVideoUrl = video.url.includes("#")
|
const previewVideoUrl = videoMetadataPreviewSource(
|
||||||
? video.url
|
videoPreviewSource(video, useMobilePreview),
|
||||||
: `${video.url}#t=0.1`;
|
);
|
||||||
const duration = formatDuration(video.durationSec);
|
const duration = formatDuration(video.durationSec);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
34
src/components/messageStream/hooks/useVideoPreviewSource.ts
Normal file
34
src/components/messageStream/hooks/useVideoPreviewSource.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Attachment } from "../../../types/post";
|
||||||
|
import {
|
||||||
|
mobilePreviewMediaQuery,
|
||||||
|
videoPreviewSource,
|
||||||
|
} from "../utils/videoPreviewSource";
|
||||||
|
|
||||||
|
function getMatchesMobilePreview(): boolean {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||||
|
return window.matchMedia(mobilePreviewMediaQuery).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShouldUseMobilePreview(): boolean {
|
||||||
|
const [useMobilePreview, setUseMobilePreview] = useState(
|
||||||
|
getMatchesMobilePreview,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||||
|
|
||||||
|
const media = window.matchMedia(mobilePreviewMediaQuery);
|
||||||
|
const update = () => setUseMobilePreview(media.matches);
|
||||||
|
update();
|
||||||
|
|
||||||
|
media.addEventListener("change", update);
|
||||||
|
return () => media.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMobilePreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoPreviewSource(attachment: Attachment): string {
|
||||||
|
return videoPreviewSource(attachment, useShouldUseMobilePreview());
|
||||||
|
}
|
||||||
@@ -5,26 +5,26 @@ describe("formatBytes", () => {
|
|||||||
it("returns bytes under 1 KB unchanged", () => {
|
it("returns bytes under 1 KB unchanged", () => {
|
||||||
expect(formatBytes(0)).toBe("0 B");
|
expect(formatBytes(0)).toBe("0 B");
|
||||||
expect(formatBytes(512)).toBe("512 B");
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
expect(formatBytes(1023)).toBe("1023 B");
|
expect(formatBytes(999)).toBe("999 B");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats KB with one decimal when small", () => {
|
it("formats KB with one decimal when small", () => {
|
||||||
expect(formatBytes(1024)).toBe("1 KB");
|
expect(formatBytes(1000)).toBe("1 KB");
|
||||||
expect(formatBytes(1536)).toBe("1.5 KB");
|
expect(formatBytes(1500)).toBe("1.5 KB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats MB with one decimal", () => {
|
it("formats MB with one decimal", () => {
|
||||||
expect(formatBytes(3_549_239)).toBe("3.4 MB");
|
expect(formatBytes(3_400_000)).toBe("3.4 MB");
|
||||||
expect(formatBytes(4_800_000)).toBe("4.6 MB");
|
expect(formatBytes(4_600_000)).toBe("4.6 MB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops decimals once value >= 100", () => {
|
it("drops decimals once value >= 100", () => {
|
||||||
expect(formatBytes(150 * 1024 * 1024)).toBe("150 MB");
|
expect(formatBytes(150 * 1000 * 1000)).toBe("150 MB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles GB and TB", () => {
|
it("handles GB and TB", () => {
|
||||||
expect(formatBytes(2 * 1024 ** 3)).toBe("2 GB");
|
expect(formatBytes(2 * 1000 ** 3)).toBe("2 GB");
|
||||||
expect(formatBytes(3 * 1024 ** 4)).toBe("3 TB");
|
expect(formatBytes(3 * 1000 ** 4)).toBe("3 TB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guards against invalid input", () => {
|
it("guards against invalid input", () => {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ const UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
|
|||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1000) return `${bytes} B`;
|
||||||
let value = bytes;
|
let value = bytes;
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
while (value >= 1024 && unitIndex < UNITS.length - 1) {
|
while (value >= 1000 && unitIndex < UNITS.length - 1) {
|
||||||
value /= 1024;
|
value /= 1000;
|
||||||
unitIndex += 1;
|
unitIndex += 1;
|
||||||
}
|
}
|
||||||
const rounded =
|
const rounded =
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Attachment } from "../../../types/post";
|
||||||
|
import {
|
||||||
|
videoMetadataPreviewSource,
|
||||||
|
videoPreviewSource,
|
||||||
|
} from "./videoPreviewSource";
|
||||||
|
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: "att-1",
|
||||||
|
kind: "video",
|
||||||
|
url: "/uploads/desktop-preview.mp4",
|
||||||
|
mobilePreviewUrl: "/uploads/mobile-540p-preview.mp4",
|
||||||
|
mime: "video/mp4",
|
||||||
|
filename: "original.mp4",
|
||||||
|
sizeBytes: 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("videoPreviewSource", () => {
|
||||||
|
it("uses the desktop preview by default", () => {
|
||||||
|
expect(videoPreviewSource(attachment, false)).toBe(
|
||||||
|
"/uploads/desktop-preview.mp4",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses mobilePreviewUrl only when mobile preview is active", () => {
|
||||||
|
expect(videoPreviewSource(attachment, true)).toBe(
|
||||||
|
"/uploads/mobile-540p-preview.mp4",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the desktop preview when mobilePreviewUrl is absent", () => {
|
||||||
|
expect(
|
||||||
|
videoPreviewSource({ ...attachment, mobilePreviewUrl: undefined }, true),
|
||||||
|
).toBe("/uploads/desktop-preview.mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a metadata seek fragment only when the URL has no fragment", () => {
|
||||||
|
expect(videoMetadataPreviewSource("/uploads/video.mp4")).toBe(
|
||||||
|
"/uploads/video.mp4#t=0.1",
|
||||||
|
);
|
||||||
|
expect(videoMetadataPreviewSource("/uploads/video.mp4#t=2")).toBe(
|
||||||
|
"/uploads/video.mp4#t=2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/components/messageStream/utils/videoPreviewSource.ts
Normal file
17
src/components/messageStream/utils/videoPreviewSource.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Attachment } from "../../../types/post";
|
||||||
|
|
||||||
|
export const mobilePreviewMediaQuery = "(max-width: 760px)";
|
||||||
|
|
||||||
|
export function videoPreviewSource(
|
||||||
|
attachment: Attachment,
|
||||||
|
useMobilePreview: boolean,
|
||||||
|
): string {
|
||||||
|
if (useMobilePreview && attachment.mobilePreviewUrl) {
|
||||||
|
return attachment.mobilePreviewUrl;
|
||||||
|
}
|
||||||
|
return attachment.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoMetadataPreviewSource(url: string): string {
|
||||||
|
return url.includes("#") ? url : `${url}#t=0.1`;
|
||||||
|
}
|
||||||
380
src/i18n.tsx
380
src/i18n.tsx
@@ -5,359 +5,30 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { languageForHomePathname } from "./languageRoutes";
|
import {
|
||||||
|
languageForHomePathname,
|
||||||
|
languageFromPathname,
|
||||||
|
langPathPrefix,
|
||||||
|
} from "./languageRoutes";
|
||||||
|
import type { Dict } from "./locales/types";
|
||||||
|
import { zhDict } from "./locales/zh-CN";
|
||||||
|
import { enDict } from "./locales/en";
|
||||||
|
import { jaDict } from "./locales/ja";
|
||||||
|
import { koDict } from "./locales/ko";
|
||||||
|
import { viDict } from "./locales/vi";
|
||||||
|
import { idDict } from "./locales/id";
|
||||||
|
import { msDict } from "./locales/ms";
|
||||||
|
|
||||||
export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
|
export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
|
||||||
|
|
||||||
type Dict = Record<string, string>;
|
|
||||||
|
|
||||||
const zhDict: Dict = {
|
|
||||||
brand: "ARK 资料库",
|
|
||||||
mainNav: "网站导航",
|
|
||||||
home: "首页",
|
|
||||||
all: "全部资料",
|
|
||||||
categories: "资料分类",
|
|
||||||
latest: "最新更新",
|
|
||||||
official: "官方推荐",
|
|
||||||
popular: "热门资料",
|
|
||||||
search: "搜索",
|
|
||||||
searchPlaceholder: "搜索资料...",
|
|
||||||
searchPanelPlaceholder: "搜索资料...",
|
|
||||||
searchNow: "立即搜索资料",
|
|
||||||
searchSubmit: "搜索",
|
|
||||||
cancel: "取消",
|
|
||||||
clear: "清除",
|
|
||||||
searchPanelHint: "支持搜索 标题・分类・标签・简介・文件类型・正文",
|
|
||||||
currentTags: "现有标签",
|
|
||||||
noTagsAvailable: "暂无可选择的标签。",
|
|
||||||
tagPostsTitle: "#{{tag}} 相关资料",
|
|
||||||
noTagPosts: "暂时找不到带有此标签的资料。",
|
|
||||||
viewAll: "查看全部",
|
|
||||||
backToTop: "回到顶部",
|
|
||||||
heroTitle: "ARK 官方数据库",
|
|
||||||
heroSub:
|
|
||||||
"集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
|
|
||||||
categorySection: "资料分类",
|
|
||||||
officialSection: "官方推荐",
|
|
||||||
latestSection: "最新更新",
|
|
||||||
popularSection: "热门资料",
|
|
||||||
preview: "预览",
|
|
||||||
download: "下载",
|
|
||||||
downloading: "下载中…",
|
|
||||||
downloadOk: "下载完成",
|
|
||||||
downloadFail: "下载失败,请重试",
|
|
||||||
longPressImageSave: "长按图片保存到相册",
|
|
||||||
showMore: "展开全部",
|
|
||||||
showLess: "收起全部",
|
|
||||||
share: "分享",
|
|
||||||
langLabel: "语言",
|
|
||||||
admin: "后台",
|
|
||||||
login: "登录",
|
|
||||||
logout: "退出",
|
|
||||||
email: "邮箱",
|
|
||||||
password: "密码",
|
|
||||||
dashboard: "仪表盘",
|
|
||||||
resources: "资料管理",
|
|
||||||
newResource: "新增资料",
|
|
||||||
save: "保存",
|
|
||||||
title: "标题",
|
|
||||||
description: "简介",
|
|
||||||
type: "类型",
|
|
||||||
language: "语言",
|
|
||||||
category: "分类",
|
|
||||||
status: "状态",
|
|
||||||
public: "公开",
|
|
||||||
downloadable: "可下载",
|
|
||||||
recommended: "首页推荐",
|
|
||||||
cover: "封面图 URL",
|
|
||||||
fileUrl: "文件 URL",
|
|
||||||
externalUrl: "外部链接",
|
|
||||||
body: "文案内容",
|
|
||||||
badge: "推荐标签",
|
|
||||||
published: "已发布",
|
|
||||||
draft: "草稿",
|
|
||||||
archived: "归档",
|
|
||||||
noResults: "找不到符合的资料,请换个关键字或浏览分类。",
|
|
||||||
copyLink: "复制链接",
|
|
||||||
related: "相关资料",
|
|
||||||
total: "总资料",
|
|
||||||
views: "浏览",
|
|
||||||
downloads: "下载",
|
|
||||||
lang_zh_CN: "中文",
|
|
||||||
lang_en: "English",
|
|
||||||
lang_ja: "日本語",
|
|
||||||
lang_ko: "한국어",
|
|
||||||
lang_vi: "Tiếng Việt",
|
|
||||||
lang_id: "Bahasa Indonesia",
|
|
||||||
lang_ms: "Bahasa Melayu",
|
|
||||||
filterAll: "全部",
|
|
||||||
sortPublished: "发布时间",
|
|
||||||
type_ppt: "PPT",
|
|
||||||
type_music: "音乐",
|
|
||||||
type_video: "视频",
|
|
||||||
type_image: "图片",
|
|
||||||
type_pdf: "PDF",
|
|
||||||
type_link: "链接",
|
|
||||||
type_text: "文字",
|
|
||||||
type_archive: "压缩包",
|
|
||||||
type_zip: "ZIP",
|
|
||||||
adminLoginTitle: "管理后台登录",
|
|
||||||
adminEditResource: "编辑资料",
|
|
||||||
adminVideoFileHint:
|
|
||||||
"上传视频文件(MP4/WebM/MOV 等),类型请选择「视频」;保存后前台自动播放(默认静音,可点喇叭开声音)。",
|
|
||||||
adminStatTodayNew: "今日新增",
|
|
||||||
adminStatFavorites: "收藏",
|
|
||||||
adminMetricDownloads: "下载",
|
|
||||||
adminMetricFavorites: "收藏",
|
|
||||||
adminMetricViews: "浏览",
|
|
||||||
edit: "编辑",
|
|
||||||
backToList: "返回列表",
|
|
||||||
sortOrderLabel: "排序权重",
|
|
||||||
previewUrlLabel: "预览网址",
|
|
||||||
tagsCommaLabel: "标签(逗号分隔)",
|
|
||||||
uploadFile: "上传文件",
|
|
||||||
loading: "加载中…",
|
|
||||||
paginationPrev: "上一页",
|
|
||||||
paginationNext: "下一页",
|
|
||||||
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
|
||||||
pageIndicator: "{{c}} / {{p}} 页",
|
|
||||||
resourceLangFilter: "资料语言",
|
|
||||||
filterTagClear: "清除标签",
|
|
||||||
filterLanguageAll: "全部语言",
|
|
||||||
footerAdminLogin: "管理员登录",
|
|
||||||
adminSearchLogs: "搜索记录",
|
|
||||||
adminMetricShares: "分享",
|
|
||||||
adminSearchQuery: "查询词",
|
|
||||||
adminSearchTime: "时间",
|
|
||||||
adminSearchId: "编号",
|
|
||||||
favorites: "我的收藏",
|
|
||||||
favoritesComingSoon: "功能即将推出",
|
|
||||||
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
|
||||||
featureUnavailable: "未开放",
|
|
||||||
featureUnavailableDesc: "该功能暂未开放。",
|
|
||||||
confirm: "知道了",
|
|
||||||
backToHome: "返回首页",
|
|
||||||
};
|
|
||||||
|
|
||||||
const enDict: Dict = {
|
|
||||||
brand: "ARK Library",
|
|
||||||
mainNav: "Site menu",
|
|
||||||
home: "Home",
|
|
||||||
all: "All assets",
|
|
||||||
categories: "Categories",
|
|
||||||
latest: "Latest",
|
|
||||||
official: "Official picks",
|
|
||||||
popular: "Popular",
|
|
||||||
search: "Search",
|
|
||||||
searchPlaceholder: "Search resources...",
|
|
||||||
searchPanelPlaceholder: "Search assets...",
|
|
||||||
searchNow: "Search now",
|
|
||||||
searchSubmit: "Search",
|
|
||||||
cancel: "Cancel",
|
|
||||||
clear: "Clear",
|
|
||||||
searchPanelHint:
|
|
||||||
"Search supports title, category, tags, summary, file type, and body text.",
|
|
||||||
currentTags: "Available tags",
|
|
||||||
noTagsAvailable: "No tags available yet.",
|
|
||||||
tagPostsTitle: "#{{tag}} related posts",
|
|
||||||
noTagPosts: "No posts with this tag yet.",
|
|
||||||
viewAll: "View all",
|
|
||||||
backToTop: "Back to top",
|
|
||||||
heroTitle: "ARK Official Library",
|
|
||||||
heroSub:
|
|
||||||
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
|
|
||||||
categorySection: "Categories",
|
|
||||||
officialSection: "Official recommendations",
|
|
||||||
latestSection: "Latest updates",
|
|
||||||
popularSection: "Popular assets",
|
|
||||||
preview: "Preview",
|
|
||||||
download: "Download",
|
|
||||||
downloading: "Downloading…",
|
|
||||||
downloadOk: "Download complete",
|
|
||||||
downloadFail: "Download failed, please retry",
|
|
||||||
longPressImageSave: "Long-press image to save",
|
|
||||||
showMore: "Show all",
|
|
||||||
showLess: "Show less",
|
|
||||||
share: "Share",
|
|
||||||
langLabel: "Language",
|
|
||||||
admin: "Admin",
|
|
||||||
login: "Sign in",
|
|
||||||
logout: "Sign out",
|
|
||||||
email: "Email",
|
|
||||||
password: "Password",
|
|
||||||
dashboard: "Dashboard",
|
|
||||||
resources: "Resources",
|
|
||||||
newResource: "New resource",
|
|
||||||
save: "Save",
|
|
||||||
title: "Title",
|
|
||||||
description: "Description",
|
|
||||||
type: "Type",
|
|
||||||
language: "Language",
|
|
||||||
category: "Category",
|
|
||||||
status: "Status",
|
|
||||||
public: "Public",
|
|
||||||
downloadable: "Downloadable",
|
|
||||||
recommended: "Featured",
|
|
||||||
cover: "Cover image URL",
|
|
||||||
fileUrl: "File URL",
|
|
||||||
externalUrl: "External URL",
|
|
||||||
body: "Text body",
|
|
||||||
badge: "Badge label",
|
|
||||||
published: "Published",
|
|
||||||
draft: "Draft",
|
|
||||||
archived: "Archived",
|
|
||||||
noResults: "No results. Try another keyword or browse categories.",
|
|
||||||
copyLink: "Copy link",
|
|
||||||
related: "Related",
|
|
||||||
total: "Total items",
|
|
||||||
views: "Views",
|
|
||||||
downloads: "Downloads",
|
|
||||||
lang_zh_CN: "Chinese",
|
|
||||||
lang_en: "English",
|
|
||||||
lang_ja: "Japanese",
|
|
||||||
lang_ko: "Korean",
|
|
||||||
lang_vi: "Vietnamese",
|
|
||||||
lang_id: "Indonesian",
|
|
||||||
lang_ms: "Malay",
|
|
||||||
filterAll: "All types",
|
|
||||||
sortPublished: "Published date",
|
|
||||||
type_ppt: "PPT",
|
|
||||||
type_music: "Music",
|
|
||||||
type_video: "Video",
|
|
||||||
type_image: "Image",
|
|
||||||
type_pdf: "PDF",
|
|
||||||
type_link: "Link",
|
|
||||||
type_text: "Text",
|
|
||||||
type_archive: "Archive",
|
|
||||||
type_zip: "ZIP",
|
|
||||||
adminLoginTitle: "Admin sign in",
|
|
||||||
adminEditResource: "Edit resource",
|
|
||||||
adminVideoFileHint:
|
|
||||||
"Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
|
|
||||||
adminStatTodayNew: "New today",
|
|
||||||
adminStatFavorites: "Favorites",
|
|
||||||
adminMetricDownloads: "Downloads",
|
|
||||||
adminMetricFavorites: "Favorites",
|
|
||||||
adminMetricViews: "Views",
|
|
||||||
edit: "Edit",
|
|
||||||
backToList: "Back to list",
|
|
||||||
sortOrderLabel: "Sort order",
|
|
||||||
previewUrlLabel: "Preview URL",
|
|
||||||
tagsCommaLabel: "Tags (comma-separated)",
|
|
||||||
uploadFile: "Upload",
|
|
||||||
loading: "Loading…",
|
|
||||||
paginationPrev: "Previous",
|
|
||||||
paginationNext: "Next",
|
|
||||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
|
||||||
pageIndicator: "Page {{c}} / {{p}}",
|
|
||||||
resourceLangFilter: "Resource language",
|
|
||||||
filterTagClear: "Clear tag",
|
|
||||||
filterLanguageAll: "All languages",
|
|
||||||
footerAdminLogin: "Admin sign-in",
|
|
||||||
adminSearchLogs: "Search logs",
|
|
||||||
adminMetricShares: "Shares",
|
|
||||||
adminSearchQuery: "Query",
|
|
||||||
adminSearchTime: "Time",
|
|
||||||
adminSearchId: "ID",
|
|
||||||
favorites: "My Favorites",
|
|
||||||
favoritesComingSoon: "Coming Soon",
|
|
||||||
favoritesComingSoonDesc:
|
|
||||||
"Sign-in and favorites are in development. Stay tuned.",
|
|
||||||
featureUnavailable: "Not available yet",
|
|
||||||
featureUnavailableDesc: "This feature is not available yet.",
|
|
||||||
confirm: "Got it",
|
|
||||||
backToHome: "Back to Home",
|
|
||||||
};
|
|
||||||
|
|
||||||
const languageNames: Record<Lang, Dict> = {
|
|
||||||
"zh-CN": {
|
|
||||||
lang_zh_CN: "中文",
|
|
||||||
lang_en: "English",
|
|
||||||
lang_ja: "日本語",
|
|
||||||
lang_ko: "한국어",
|
|
||||||
lang_vi: "Tiếng Việt",
|
|
||||||
lang_id: "Bahasa Indonesia",
|
|
||||||
lang_ms: "Bahasa Melayu",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
lang_zh_CN: "Chinese",
|
|
||||||
lang_en: "English",
|
|
||||||
lang_ja: "Japanese",
|
|
||||||
lang_ko: "Korean",
|
|
||||||
lang_vi: "Vietnamese",
|
|
||||||
lang_id: "Indonesian",
|
|
||||||
lang_ms: "Malay",
|
|
||||||
},
|
|
||||||
ja: {
|
|
||||||
brand: "ARK ライブラリー",
|
|
||||||
showMore: "すべて表示",
|
|
||||||
showLess: "閉じる",
|
|
||||||
lang_zh_CN: "中国語",
|
|
||||||
lang_en: "英語",
|
|
||||||
lang_ja: "日本語",
|
|
||||||
lang_ko: "韓国語",
|
|
||||||
lang_vi: "ベトナム語",
|
|
||||||
lang_id: "インドネシア語",
|
|
||||||
lang_ms: "マレー語",
|
|
||||||
},
|
|
||||||
ko: {
|
|
||||||
brand: "ARK 라이브러리",
|
|
||||||
showMore: "모두 보기",
|
|
||||||
showLess: "접기",
|
|
||||||
lang_zh_CN: "중국어",
|
|
||||||
lang_en: "영어",
|
|
||||||
lang_ja: "일본어",
|
|
||||||
lang_ko: "한국어",
|
|
||||||
lang_vi: "베트남어",
|
|
||||||
lang_id: "인도네시아어",
|
|
||||||
lang_ms: "말레이어",
|
|
||||||
},
|
|
||||||
vi: {
|
|
||||||
brand: "Thư viện ARK",
|
|
||||||
showMore: "Xem tất cả",
|
|
||||||
showLess: "Thu gọn",
|
|
||||||
lang_zh_CN: "Tiếng Trung",
|
|
||||||
lang_en: "Tiếng Anh",
|
|
||||||
lang_ja: "Tiếng Nhật",
|
|
||||||
lang_ko: "Tiếng Hàn",
|
|
||||||
lang_vi: "Tiếng Việt",
|
|
||||||
lang_id: "Tiếng Indonesia",
|
|
||||||
lang_ms: "Tiếng Mã Lai",
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
brand: "Perpustakaan ARK",
|
|
||||||
showMore: "Lihat semua",
|
|
||||||
showLess: "Tutup",
|
|
||||||
lang_zh_CN: "Bahasa Tionghoa",
|
|
||||||
lang_en: "Bahasa Inggris",
|
|
||||||
lang_ja: "Bahasa Jepang",
|
|
||||||
lang_ko: "Bahasa Korea",
|
|
||||||
lang_vi: "Bahasa Vietnam",
|
|
||||||
lang_id: "Bahasa Indonesia",
|
|
||||||
lang_ms: "Bahasa Melayu",
|
|
||||||
},
|
|
||||||
ms: {
|
|
||||||
brand: "Perpustakaan ARK",
|
|
||||||
showMore: "Lihat semua",
|
|
||||||
showLess: "Tutup",
|
|
||||||
lang_zh_CN: "Bahasa Cina",
|
|
||||||
lang_en: "Bahasa Inggeris",
|
|
||||||
lang_ja: "Bahasa Jepun",
|
|
||||||
lang_ko: "Bahasa Korea",
|
|
||||||
lang_vi: "Bahasa Vietnam",
|
|
||||||
lang_id: "Bahasa Indonesia",
|
|
||||||
lang_ms: "Bahasa Melayu",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dict: Record<Lang, Dict> = {
|
const dict: Record<Lang, Dict> = {
|
||||||
"zh-CN": { ...zhDict, ...languageNames["zh-CN"] },
|
"zh-CN": zhDict,
|
||||||
en: { ...enDict, ...languageNames.en },
|
en: enDict,
|
||||||
ja: { ...enDict, ...languageNames.ja },
|
ja: jaDict,
|
||||||
ko: { ...enDict, ...languageNames.ko },
|
ko: koDict,
|
||||||
vi: { ...enDict, ...languageNames.vi },
|
vi: viDict,
|
||||||
id: { ...enDict, ...languageNames.id },
|
id: idDict,
|
||||||
ms: { ...enDict, ...languageNames.ms },
|
ms: msDict,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fixed locale lookup (admin UI uses Simplified Chinese). */
|
/** Fixed locale lookup (admin UI uses Simplified Chinese). */
|
||||||
@@ -373,9 +44,14 @@ const LANG_KEY = "ark_lang";
|
|||||||
|
|
||||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [lang, setLangState] = useState<Lang>(() => {
|
const [lang, setLangState] = useState<Lang>(() => {
|
||||||
const routeLang = languageForHomePathname(window.location.pathname);
|
const path = window.location.pathname;
|
||||||
if (routeLang) return routeLang;
|
// Any URL whose first path segment is a known language prefix wins
|
||||||
if (window.location.pathname === "/") return "en";
|
// (covers /malay, /malay/browse, /korean/category/foo, etc.).
|
||||||
|
const homeLang = languageForHomePathname(path);
|
||||||
|
if (homeLang) return homeLang;
|
||||||
|
const deepLang = languageFromPathname(path);
|
||||||
|
if (langPathPrefix(deepLang)) return deepLang;
|
||||||
|
if (path === "/") return "en";
|
||||||
|
|
||||||
const s = localStorage.getItem(LANG_KEY);
|
const s = localStorage.getItem(LANG_KEY);
|
||||||
if (s === "zh" || s === "zh-TW") return "zh-CN";
|
if (s === "zh" || s === "zh-TW") return "zh-CN";
|
||||||
|
|||||||
@@ -33,3 +33,55 @@ export function homePathForLang(lang: Lang): string {
|
|||||||
if (lang === "en") return "/";
|
if (lang === "en") return "/";
|
||||||
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/";
|
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the URL prefix for a language (e.g. "/malay"), or "" for English. */
|
||||||
|
export function langPathPrefix(lang: Lang): string {
|
||||||
|
if (lang === "en") return "";
|
||||||
|
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detects which language a URL belongs to by inspecting the path prefix. */
|
||||||
|
export function languageFromPathname(pathname: string): Lang {
|
||||||
|
const normalized = normalizePathname(pathname);
|
||||||
|
for (const route of localizedHomeRoutes) {
|
||||||
|
if (normalized === route.path || normalized.startsWith(route.path + "/")) {
|
||||||
|
return route.lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepends a language prefix to a path. Path may include `?query` or `#hash`;
|
||||||
|
* the prefix is inserted before the pathname only.
|
||||||
|
*
|
||||||
|
* localizePath("/browse", "ms") -> "/malay/browse"
|
||||||
|
* localizePath("/", "ms") -> "/malay"
|
||||||
|
* localizePath("/browse", "en") -> "/browse"
|
||||||
|
*/
|
||||||
|
export function localizePath(path: string, lang: Lang): string {
|
||||||
|
const prefix = langPathPrefix(lang);
|
||||||
|
if (!prefix) return path;
|
||||||
|
if (!path.startsWith("/")) path = "/" + path;
|
||||||
|
if (path === "/") return prefix;
|
||||||
|
return prefix + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes any known language prefix from a pathname. Useful when comparing
|
||||||
|
* the current route against canonical (unprefixed) paths.
|
||||||
|
*
|
||||||
|
* stripLangPrefix("/malay/browse") -> "/browse"
|
||||||
|
* stripLangPrefix("/malay") -> "/"
|
||||||
|
* stripLangPrefix("/browse") -> "/browse"
|
||||||
|
*/
|
||||||
|
export function stripLangPrefix(pathname: string): string {
|
||||||
|
const normalized = normalizePathname(pathname);
|
||||||
|
for (const route of localizedHomeRoutes) {
|
||||||
|
if (normalized === route.path) return "/";
|
||||||
|
if (normalized.startsWith(route.path + "/")) {
|
||||||
|
return normalized.slice(route.path.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import { DocumentMeta } from "../components/DocumentMeta";
|
|||||||
import { SearchPanel } from "../components/SearchPanel";
|
import { SearchPanel } from "../components/SearchPanel";
|
||||||
import { useI18n, type Lang } from "../i18n";
|
import { useI18n, type Lang } from "../i18n";
|
||||||
import { LANG_OPTIONS } from "../i18nLanguages";
|
import { LANG_OPTIONS } from "../i18nLanguages";
|
||||||
import { homePathForLang, isHomePathname } from "../languageRoutes";
|
import {
|
||||||
|
homePathForLang,
|
||||||
|
isHomePathname,
|
||||||
|
languageFromPathname,
|
||||||
|
localizePath,
|
||||||
|
stripLangPrefix,
|
||||||
|
} from "../languageRoutes";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
|
|
||||||
type PublicNavWhich =
|
type PublicNavWhich =
|
||||||
| "home"
|
| "home"
|
||||||
@@ -29,25 +36,26 @@ function navIsActive(
|
|||||||
which: PublicNavWhich,
|
which: PublicNavWhich,
|
||||||
): boolean {
|
): boolean {
|
||||||
const sp = new URLSearchParams(search);
|
const sp = new URLSearchParams(search);
|
||||||
|
const stripped = stripLangPrefix(pathname);
|
||||||
switch (which) {
|
switch (which) {
|
||||||
case "home":
|
case "home":
|
||||||
return isHomePathname(pathname);
|
return isHomePathname(pathname);
|
||||||
case "browseAll":
|
case "browseAll":
|
||||||
return pathname === "/browse" && !sp.has("sort");
|
return stripped === "/browse" && !sp.has("sort");
|
||||||
case "categories":
|
case "categories":
|
||||||
return (
|
return (
|
||||||
pathname === "/categories" ||
|
stripped === "/categories" ||
|
||||||
(isHomePathname(pathname) && hash === "#categories")
|
(isHomePathname(pathname) && hash === "#categories")
|
||||||
);
|
);
|
||||||
case "browseLatest":
|
case "browseLatest":
|
||||||
return pathname === "/browse" && sp.get("sort") === "latest";
|
return stripped === "/browse" && sp.get("sort") === "latest";
|
||||||
case "browseRecommended":
|
case "browseRecommended":
|
||||||
return pathname === "/official-recommendations";
|
return stripped === "/official-recommendations";
|
||||||
case "browsePopular":
|
case "browsePopular":
|
||||||
return pathname === "/browse" && sp.get("sort") === "popular";
|
return stripped === "/browse" && sp.get("sort") === "popular";
|
||||||
case "favorites":
|
case "favorites":
|
||||||
return (
|
return (
|
||||||
pathname === "/favorites" ||
|
stripped === "/favorites" ||
|
||||||
(isHomePathname(pathname) && hash === "#favorites")
|
(isHomePathname(pathname) && hash === "#favorites")
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -295,6 +303,15 @@ export function PublicLayout() {
|
|||||||
const desktopSearchRef = useRef<HTMLDivElement>(null);
|
const desktopSearchRef = useRef<HTMLDivElement>(null);
|
||||||
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
|
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
|
|
||||||
|
// Keep i18n state in sync with URL so deep links (`/malay/browse`) flip the
|
||||||
|
// UI language even if the user navigated via address bar or shared link.
|
||||||
|
useEffect(() => {
|
||||||
|
const urlLang = languageFromPathname(pathname);
|
||||||
|
if (urlLang !== lang) setLang(urlLang);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
const na = (which: PublicNavWhich) =>
|
const na = (which: PublicNavWhich) =>
|
||||||
navIsActive(pathname, search, hash, which);
|
navIsActive(pathname, search, hash, which);
|
||||||
@@ -302,9 +319,17 @@ export function PublicLayout() {
|
|||||||
const homePath = homePathForLang(lang);
|
const homePath = homePathForLang(lang);
|
||||||
const changeLang = (nextLang: Lang) => {
|
const changeLang = (nextLang: Lang) => {
|
||||||
setLang(nextLang);
|
setLang(nextLang);
|
||||||
if (isHome) nav(homePathForLang(nextLang), { replace: true });
|
if (isHome) {
|
||||||
|
nav(homePathForLang(nextLang), { replace: true });
|
||||||
|
} else {
|
||||||
|
// Preserve sub-path and query/hash; only swap the language prefix.
|
||||||
|
const canonical = stripLangPrefix(pathname);
|
||||||
|
nav(localizePath(canonical, nextLang) + search + hash, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const footerInContentFlow = pathname === "/browse";
|
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
|
||||||
// Current page name shown in the header brand slot (falls back to the brand).
|
// Current page name shown in the header brand slot (falls back to the brand).
|
||||||
const pageTitle = usePageTitle();
|
const pageTitle = usePageTitle();
|
||||||
|
|
||||||
@@ -344,12 +369,12 @@ export function PublicLayout() {
|
|||||||
if (idleId) ric.cancelIdleCallback?.(idleId);
|
if (idleId) ric.cancelIdleCallback?.(idleId);
|
||||||
};
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
const popularHref = "/browse?sort=popular";
|
const popularHref = lp("/browse?sort=popular");
|
||||||
|
|
||||||
const goSearch = () => {
|
const goSearch = () => {
|
||||||
const s = q.trim();
|
const s = q.trim();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
nav(`/browse?q=${encodeURIComponent(s)}`);
|
nav(lp(`/browse?q=${encodeURIComponent(s)}`));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMobileSearchOpen(false);
|
setMobileSearchOpen(false);
|
||||||
setDesktopSearchOpen(false);
|
setDesktopSearchOpen(false);
|
||||||
@@ -563,7 +588,7 @@ export function PublicLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
className="max-w-[10rem] truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg sm:inline"
|
className="max-w-[14rem] truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg sm:inline"
|
||||||
>
|
>
|
||||||
{pageTitle || t("brand")}
|
{pageTitle || t("brand")}
|
||||||
</button>
|
</button>
|
||||||
@@ -574,35 +599,35 @@ export function PublicLayout() {
|
|||||||
aria-label={t("mainNav")}
|
aria-label={t("mainNav")}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/browse"
|
to={lp("/browse")}
|
||||||
className={navClassName(na("browseAll"))}
|
className={navClassName(na("browseAll"))}
|
||||||
aria-current={na("browseAll") ? "page" : undefined}
|
aria-current={na("browseAll") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("all")}
|
{t("all")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/categories"
|
to={lp("/categories")}
|
||||||
className={navClassName(na("categories"))}
|
className={navClassName(na("categories"))}
|
||||||
aria-current={na("categories") ? "page" : undefined}
|
aria-current={na("categories") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/official-recommendations"
|
to={lp("/official-recommendations")}
|
||||||
className={navClassName(na("browseRecommended"))}
|
className={navClassName(na("browseRecommended"))}
|
||||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("official")}
|
{t("official")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to={lp("/browse?sort=latest")}
|
||||||
className={navClassName(na("browseLatest"))}
|
className={navClassName(na("browseLatest"))}
|
||||||
aria-current={na("browseLatest") ? "page" : undefined}
|
aria-current={na("browseLatest") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("latest")}
|
{t("latest")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/favorites"
|
to={lp("/favorites")}
|
||||||
className={navClassName(na("favorites"))}
|
className={navClassName(na("favorites"))}
|
||||||
aria-current={na("favorites") ? "page" : undefined}
|
aria-current={na("favorites") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
@@ -661,7 +686,7 @@ export function PublicLayout() {
|
|||||||
className={`${headerMenuAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 bg-[#08070c] px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1000px]:hidden`}
|
className={`${headerMenuAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 bg-[#08070c] px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1000px]:hidden`}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/browse"
|
to={lp("/browse")}
|
||||||
className={mobileMenuNavClassName(na("browseAll"))}
|
className={mobileMenuNavClassName(na("browseAll"))}
|
||||||
aria-current={na("browseAll") ? "page" : undefined}
|
aria-current={na("browseAll") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -669,7 +694,7 @@ export function PublicLayout() {
|
|||||||
{t("all")}
|
{t("all")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/categories"
|
to={lp("/categories")}
|
||||||
className={mobileMenuNavClassName(na("categories"))}
|
className={mobileMenuNavClassName(na("categories"))}
|
||||||
aria-current={na("categories") ? "page" : undefined}
|
aria-current={na("categories") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -677,7 +702,7 @@ export function PublicLayout() {
|
|||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/official-recommendations"
|
to={lp("/official-recommendations")}
|
||||||
className={mobileMenuNavClassName(na("browseRecommended"))}
|
className={mobileMenuNavClassName(na("browseRecommended"))}
|
||||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -685,7 +710,7 @@ export function PublicLayout() {
|
|||||||
{t("official")}
|
{t("official")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to={lp("/browse?sort=latest")}
|
||||||
className={mobileMenuNavClassName(na("browseLatest"))}
|
className={mobileMenuNavClassName(na("browseLatest"))}
|
||||||
aria-current={na("browseLatest") ? "page" : undefined}
|
aria-current={na("browseLatest") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -693,7 +718,7 @@ export function PublicLayout() {
|
|||||||
{t("latest")}
|
{t("latest")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/favorites"
|
to={lp("/favorites")}
|
||||||
className={mobileMenuNavClassName(na("favorites"))}
|
className={mobileMenuNavClassName(na("favorites"))}
|
||||||
aria-current={na("favorites") ? "page" : undefined}
|
aria-current={na("favorites") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -767,15 +792,16 @@ export function PublicLayout() {
|
|||||||
active={isHome}
|
active={isHome}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/browse"
|
to={lp("/browse")}
|
||||||
label={t("all")}
|
label={t("all")}
|
||||||
icon="document"
|
icon="document"
|
||||||
active={
|
active={
|
||||||
pathname === "/browse" && !new URLSearchParams(search).get("sort")
|
stripLangPrefix(pathname) === "/browse" &&
|
||||||
|
!new URLSearchParams(search).get("sort")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/favorites"
|
to={lp("/favorites")}
|
||||||
label={t("favorites")}
|
label={t("favorites")}
|
||||||
icon="bookmark"
|
icon="bookmark"
|
||||||
active={na("favorites")}
|
active={na("favorites")}
|
||||||
@@ -789,7 +815,7 @@ export function PublicLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{pathname === "/browse" ? <BackToTop /> : null}
|
{stripLangPrefix(pathname) === "/browse" ? <BackToTop /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/locales/en.ts
Normal file
131
src/locales/en.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const enDict: Dict = {
|
||||||
|
brand: "ARK Library",
|
||||||
|
mainNav: "Site menu",
|
||||||
|
home: "Home",
|
||||||
|
all: "All assets",
|
||||||
|
categories: "Categories",
|
||||||
|
latest: "Latest",
|
||||||
|
official: "Official picks",
|
||||||
|
popular: "Popular",
|
||||||
|
search: "Search",
|
||||||
|
searchPlaceholder: "Search resources...",
|
||||||
|
searchPanelPlaceholder: "Search assets...",
|
||||||
|
searchNow: "Search now",
|
||||||
|
searchSubmit: "Search",
|
||||||
|
cancel: "Cancel",
|
||||||
|
clear: "Clear",
|
||||||
|
searchPanelHint:
|
||||||
|
"Search supports title, category, tags, summary, file type, and body text.",
|
||||||
|
currentTags: "Available tags",
|
||||||
|
noTagsAvailable: "No tags available yet.",
|
||||||
|
tagPostsTitle: "#{{tag}} related posts",
|
||||||
|
noTagPosts: "No posts with this tag yet.",
|
||||||
|
viewAll: "View all",
|
||||||
|
backToTop: "Back to top",
|
||||||
|
heroTitle: "ARK Official Library",
|
||||||
|
heroSub:
|
||||||
|
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
|
||||||
|
categorySection: "Categories",
|
||||||
|
officialSection: "Official recommendations",
|
||||||
|
latestSection: "Latest updates",
|
||||||
|
popularSection: "Popular assets",
|
||||||
|
preview: "Preview",
|
||||||
|
download: "Download",
|
||||||
|
downloading: "Downloading…",
|
||||||
|
downloadOk: "Download complete",
|
||||||
|
downloadFail: "Download failed, please retry",
|
||||||
|
longPressImageSave: "Long-press image to save",
|
||||||
|
showMore: "Show all",
|
||||||
|
showLess: "Show less",
|
||||||
|
share: "Share",
|
||||||
|
langLabel: "Language",
|
||||||
|
admin: "Admin",
|
||||||
|
login: "Sign in",
|
||||||
|
logout: "Sign out",
|
||||||
|
email: "Email",
|
||||||
|
password: "Password",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
resources: "Resources",
|
||||||
|
newResource: "New resource",
|
||||||
|
save: "Save",
|
||||||
|
title: "Title",
|
||||||
|
description: "Description",
|
||||||
|
type: "Type",
|
||||||
|
language: "Language",
|
||||||
|
category: "Category",
|
||||||
|
status: "Status",
|
||||||
|
public: "Public",
|
||||||
|
downloadable: "Downloadable",
|
||||||
|
recommended: "Featured",
|
||||||
|
cover: "Cover image URL",
|
||||||
|
fileUrl: "File URL",
|
||||||
|
externalUrl: "External URL",
|
||||||
|
body: "Text body",
|
||||||
|
badge: "Badge label",
|
||||||
|
published: "Published",
|
||||||
|
draft: "Draft",
|
||||||
|
archived: "Archived",
|
||||||
|
noResults: "No results. Try another keyword or browse categories.",
|
||||||
|
copyLink: "Copy link",
|
||||||
|
related: "Related",
|
||||||
|
total: "Total items",
|
||||||
|
views: "Views",
|
||||||
|
downloads: "Downloads",
|
||||||
|
lang_zh_CN: "Chinese",
|
||||||
|
lang_en: "English",
|
||||||
|
lang_ja: "Japanese",
|
||||||
|
lang_ko: "Korean",
|
||||||
|
lang_vi: "Vietnamese",
|
||||||
|
lang_id: "Indonesian",
|
||||||
|
lang_ms: "Malay",
|
||||||
|
filterAll: "All types",
|
||||||
|
sortPublished: "Published date",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "Music",
|
||||||
|
type_video: "Video",
|
||||||
|
type_image: "Image",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "Link",
|
||||||
|
type_text: "Text",
|
||||||
|
type_archive: "Archive",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "Admin sign in",
|
||||||
|
adminEditResource: "Edit resource",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
|
||||||
|
adminStatTodayNew: "New today",
|
||||||
|
adminStatFavorites: "Favorites",
|
||||||
|
adminMetricDownloads: "Downloads",
|
||||||
|
adminMetricFavorites: "Favorites",
|
||||||
|
adminMetricViews: "Views",
|
||||||
|
edit: "Edit",
|
||||||
|
backToList: "Back to list",
|
||||||
|
sortOrderLabel: "Sort order",
|
||||||
|
previewUrlLabel: "Preview URL",
|
||||||
|
tagsCommaLabel: "Tags (comma-separated)",
|
||||||
|
uploadFile: "Upload",
|
||||||
|
loading: "Loading…",
|
||||||
|
paginationPrev: "Previous",
|
||||||
|
paginationNext: "Next",
|
||||||
|
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
|
pageIndicator: "Page {{c}} / {{p}}",
|
||||||
|
resourceLangFilter: "Resource language",
|
||||||
|
filterTagClear: "Clear tag",
|
||||||
|
filterLanguageAll: "All languages",
|
||||||
|
footerAdminLogin: "Admin sign-in",
|
||||||
|
adminSearchLogs: "Search logs",
|
||||||
|
adminMetricShares: "Shares",
|
||||||
|
adminSearchQuery: "Query",
|
||||||
|
adminSearchTime: "Time",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
favorites: "My Favorites",
|
||||||
|
favoritesComingSoon: "Coming Soon",
|
||||||
|
favoritesComingSoonDesc:
|
||||||
|
"Sign-in and favorites are in development. Stay tuned.",
|
||||||
|
featureUnavailable: "Not available yet",
|
||||||
|
featureUnavailableDesc: "This feature is not available yet.",
|
||||||
|
confirm: "Got it",
|
||||||
|
backToHome: "Back to Home",
|
||||||
|
};
|
||||||
131
src/locales/id.ts
Normal file
131
src/locales/id.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const idDict: Dict = {
|
||||||
|
brand: "Perpustakaan ARK",
|
||||||
|
mainNav: "Menu situs",
|
||||||
|
home: "Beranda",
|
||||||
|
all: "Semua aset",
|
||||||
|
categories: "Kategori",
|
||||||
|
latest: "Terbaru",
|
||||||
|
official: "Pilihan resmi",
|
||||||
|
popular: "Populer",
|
||||||
|
search: "Cari",
|
||||||
|
searchPlaceholder: "Cari sumber daya...",
|
||||||
|
searchPanelPlaceholder: "Cari aset...",
|
||||||
|
searchNow: "Cari sekarang",
|
||||||
|
searchSubmit: "Cari",
|
||||||
|
cancel: "Batal",
|
||||||
|
clear: "Hapus",
|
||||||
|
searchPanelHint:
|
||||||
|
"Pencarian mendukung judul, kategori, tag, ringkasan, jenis file, dan isi.",
|
||||||
|
currentTags: "Tag tersedia",
|
||||||
|
noTagsAvailable: "Belum ada tag yang tersedia.",
|
||||||
|
tagPostsTitle: "Postingan terkait #{{tag}}",
|
||||||
|
noTagPosts: "Belum ada postingan dengan tag ini.",
|
||||||
|
viewAll: "Lihat semua",
|
||||||
|
backToTop: "Kembali ke atas",
|
||||||
|
heroTitle: "Perpustakaan Resmi ARK",
|
||||||
|
heroSub:
|
||||||
|
"Memusatkan, mengatur, dan mengelola perpustakaan ARK agar Anda dapat menemukan yang dibutuhkan dengan cepat dan membantu komunitas tumbuh bersama.",
|
||||||
|
categorySection: "Kategori",
|
||||||
|
officialSection: "Rekomendasi resmi",
|
||||||
|
latestSection: "Pembaruan terbaru",
|
||||||
|
popularSection: "Aset populer",
|
||||||
|
preview: "Pratinjau",
|
||||||
|
download: "Unduh",
|
||||||
|
downloading: "Mengunduh…",
|
||||||
|
downloadOk: "Unduhan selesai",
|
||||||
|
downloadFail: "Unduhan gagal, silakan coba lagi",
|
||||||
|
longPressImageSave: "Tekan lama gambar untuk menyimpan",
|
||||||
|
showMore: "Lihat semua",
|
||||||
|
showLess: "Tutup",
|
||||||
|
share: "Bagikan",
|
||||||
|
langLabel: "Bahasa",
|
||||||
|
admin: "Admin",
|
||||||
|
login: "Masuk",
|
||||||
|
logout: "Keluar",
|
||||||
|
email: "Email",
|
||||||
|
password: "Kata sandi",
|
||||||
|
dashboard: "Dasbor",
|
||||||
|
resources: "Sumber daya",
|
||||||
|
newResource: "Sumber daya baru",
|
||||||
|
save: "Simpan",
|
||||||
|
title: "Judul",
|
||||||
|
description: "Deskripsi",
|
||||||
|
type: "Jenis",
|
||||||
|
language: "Bahasa",
|
||||||
|
category: "Kategori",
|
||||||
|
status: "Status",
|
||||||
|
public: "Publik",
|
||||||
|
downloadable: "Dapat diunduh",
|
||||||
|
recommended: "Unggulan",
|
||||||
|
cover: "URL gambar sampul",
|
||||||
|
fileUrl: "URL file",
|
||||||
|
externalUrl: "Tautan eksternal",
|
||||||
|
body: "Isi teks",
|
||||||
|
badge: "Label lencana",
|
||||||
|
published: "Diterbitkan",
|
||||||
|
draft: "Draf",
|
||||||
|
archived: "Diarsipkan",
|
||||||
|
noResults: "Tidak ada hasil. Coba kata kunci lain atau telusuri kategori.",
|
||||||
|
copyLink: "Salin tautan",
|
||||||
|
related: "Terkait",
|
||||||
|
total: "Total item",
|
||||||
|
views: "Tampilan",
|
||||||
|
downloads: "Unduhan",
|
||||||
|
lang_zh_CN: "Bahasa Tionghoa",
|
||||||
|
lang_en: "Bahasa Inggris",
|
||||||
|
lang_ja: "Bahasa Jepang",
|
||||||
|
lang_ko: "Bahasa Korea",
|
||||||
|
lang_vi: "Bahasa Vietnam",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
|
filterAll: "Semua jenis",
|
||||||
|
sortPublished: "Tanggal terbit",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "Musik",
|
||||||
|
type_video: "Video",
|
||||||
|
type_image: "Gambar",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "Tautan",
|
||||||
|
type_text: "Teks",
|
||||||
|
type_archive: "Arsip",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "Masuk admin",
|
||||||
|
adminEditResource: "Edit sumber daya",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"Unggah file video (MP4/WebM/MOV, dll.) dan atur jenis ke Video; situs akan memutar otomatis (default tanpa suara — pengguna dapat membunyikan).",
|
||||||
|
adminStatTodayNew: "Baru hari ini",
|
||||||
|
adminStatFavorites: "Favorit",
|
||||||
|
adminMetricDownloads: "Unduhan",
|
||||||
|
adminMetricFavorites: "Favorit",
|
||||||
|
adminMetricViews: "Tampilan",
|
||||||
|
edit: "Edit",
|
||||||
|
backToList: "Kembali ke daftar",
|
||||||
|
sortOrderLabel: "Urutan",
|
||||||
|
previewUrlLabel: "URL pratinjau",
|
||||||
|
tagsCommaLabel: "Tag (dipisahkan koma)",
|
||||||
|
uploadFile: "Unggah",
|
||||||
|
loading: "Memuat…",
|
||||||
|
paginationPrev: "Sebelumnya",
|
||||||
|
paginationNext: "Berikutnya",
|
||||||
|
listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
|
||||||
|
pageIndicator: "Halaman {{c}} / {{p}}",
|
||||||
|
resourceLangFilter: "Bahasa sumber daya",
|
||||||
|
filterTagClear: "Hapus tag",
|
||||||
|
filterLanguageAll: "Semua bahasa",
|
||||||
|
footerAdminLogin: "Masuk admin",
|
||||||
|
adminSearchLogs: "Log pencarian",
|
||||||
|
adminMetricShares: "Berbagi",
|
||||||
|
adminSearchQuery: "Kueri",
|
||||||
|
adminSearchTime: "Waktu",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
favorites: "Favorit Saya",
|
||||||
|
favoritesComingSoon: "Segera Hadir",
|
||||||
|
favoritesComingSoonDesc:
|
||||||
|
"Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
|
||||||
|
featureUnavailable: "Belum tersedia",
|
||||||
|
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
||||||
|
confirm: "Mengerti",
|
||||||
|
backToHome: "Kembali ke Beranda",
|
||||||
|
};
|
||||||
131
src/locales/ja.ts
Normal file
131
src/locales/ja.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const jaDict: Dict = {
|
||||||
|
brand: "ARK ライブラリー",
|
||||||
|
mainNav: "サイトメニュー",
|
||||||
|
home: "ホーム",
|
||||||
|
all: "すべての資料",
|
||||||
|
categories: "カテゴリー",
|
||||||
|
latest: "最新",
|
||||||
|
official: "公式おすすめ",
|
||||||
|
popular: "人気",
|
||||||
|
search: "検索",
|
||||||
|
searchPlaceholder: "資料を検索...",
|
||||||
|
searchPanelPlaceholder: "資料を検索...",
|
||||||
|
searchNow: "今すぐ検索",
|
||||||
|
searchSubmit: "検索",
|
||||||
|
cancel: "キャンセル",
|
||||||
|
clear: "クリア",
|
||||||
|
searchPanelHint:
|
||||||
|
"タイトル・カテゴリー・タグ・概要・ファイル形式・本文の検索に対応",
|
||||||
|
currentTags: "利用可能なタグ",
|
||||||
|
noTagsAvailable: "現在利用可能なタグはありません。",
|
||||||
|
tagPostsTitle: "#{{tag}} 関連の資料",
|
||||||
|
noTagPosts: "このタグの資料はまだありません。",
|
||||||
|
viewAll: "すべて表示",
|
||||||
|
backToTop: "トップへ戻る",
|
||||||
|
heroTitle: "ARK 公式データベース",
|
||||||
|
heroSub:
|
||||||
|
"ARK ライブラリーを集約・整理・管理し、必要な資料をすばやく見つけてコミュニティの成長を促進します。",
|
||||||
|
categorySection: "カテゴリー",
|
||||||
|
officialSection: "公式おすすめ",
|
||||||
|
latestSection: "最新の更新",
|
||||||
|
popularSection: "人気の資料",
|
||||||
|
preview: "プレビュー",
|
||||||
|
download: "ダウンロード",
|
||||||
|
downloading: "ダウンロード中…",
|
||||||
|
downloadOk: "ダウンロード完了",
|
||||||
|
downloadFail: "ダウンロードに失敗しました。再試行してください",
|
||||||
|
longPressImageSave: "画像を長押しして保存",
|
||||||
|
showMore: "すべて表示",
|
||||||
|
showLess: "閉じる",
|
||||||
|
share: "シェア",
|
||||||
|
langLabel: "言語",
|
||||||
|
admin: "管理画面",
|
||||||
|
login: "ログイン",
|
||||||
|
logout: "ログアウト",
|
||||||
|
email: "メールアドレス",
|
||||||
|
password: "パスワード",
|
||||||
|
dashboard: "ダッシュボード",
|
||||||
|
resources: "資料管理",
|
||||||
|
newResource: "新規資料",
|
||||||
|
save: "保存",
|
||||||
|
title: "タイトル",
|
||||||
|
description: "説明",
|
||||||
|
type: "種類",
|
||||||
|
language: "言語",
|
||||||
|
category: "カテゴリー",
|
||||||
|
status: "ステータス",
|
||||||
|
public: "公開",
|
||||||
|
downloadable: "ダウンロード可",
|
||||||
|
recommended: "おすすめ",
|
||||||
|
cover: "カバー画像 URL",
|
||||||
|
fileUrl: "ファイル URL",
|
||||||
|
externalUrl: "外部リンク",
|
||||||
|
body: "本文",
|
||||||
|
badge: "推薦バッジ",
|
||||||
|
published: "公開済み",
|
||||||
|
draft: "下書き",
|
||||||
|
archived: "アーカイブ",
|
||||||
|
noResults:
|
||||||
|
"該当する資料が見つかりません。別のキーワードを試すか、カテゴリーをご覧ください。",
|
||||||
|
copyLink: "リンクをコピー",
|
||||||
|
related: "関連資料",
|
||||||
|
total: "総資料数",
|
||||||
|
views: "閲覧",
|
||||||
|
downloads: "ダウンロード",
|
||||||
|
lang_zh_CN: "中国語",
|
||||||
|
lang_en: "英語",
|
||||||
|
lang_ja: "日本語",
|
||||||
|
lang_ko: "韓国語",
|
||||||
|
lang_vi: "ベトナム語",
|
||||||
|
lang_id: "インドネシア語",
|
||||||
|
lang_ms: "マレー語",
|
||||||
|
filterAll: "すべての種類",
|
||||||
|
sortPublished: "公開日",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "音楽",
|
||||||
|
type_video: "動画",
|
||||||
|
type_image: "画像",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "リンク",
|
||||||
|
type_text: "テキスト",
|
||||||
|
type_archive: "アーカイブ",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "管理画面ログイン",
|
||||||
|
adminEditResource: "資料を編集",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"動画ファイル(MP4/WebM/MOV など)をアップロードし、種類を「動画」に設定すると、サイト上で自動再生されます(デフォルトはミュート、ユーザーが解除可能)。",
|
||||||
|
adminStatTodayNew: "本日の新規",
|
||||||
|
adminStatFavorites: "お気に入り",
|
||||||
|
adminMetricDownloads: "ダウンロード",
|
||||||
|
adminMetricFavorites: "お気に入り",
|
||||||
|
adminMetricViews: "閲覧",
|
||||||
|
edit: "編集",
|
||||||
|
backToList: "一覧へ戻る",
|
||||||
|
sortOrderLabel: "並び順",
|
||||||
|
previewUrlLabel: "プレビュー URL",
|
||||||
|
tagsCommaLabel: "タグ(カンマ区切り)",
|
||||||
|
uploadFile: "アップロード",
|
||||||
|
loading: "読み込み中…",
|
||||||
|
paginationPrev: "前へ",
|
||||||
|
paginationNext: "次へ",
|
||||||
|
listRange: "{{from}}–{{to}} / 全 {{total}} 件",
|
||||||
|
pageIndicator: "{{c}} / {{p}} ページ",
|
||||||
|
resourceLangFilter: "資料の言語",
|
||||||
|
filterTagClear: "タグをクリア",
|
||||||
|
filterLanguageAll: "すべての言語",
|
||||||
|
footerAdminLogin: "管理者ログイン",
|
||||||
|
adminSearchLogs: "検索履歴",
|
||||||
|
adminMetricShares: "シェア",
|
||||||
|
adminSearchQuery: "検索キーワード",
|
||||||
|
adminSearchTime: "時刻",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
favorites: "お気に入り",
|
||||||
|
favoritesComingSoon: "近日公開",
|
||||||
|
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
||||||
|
featureUnavailable: "未公開",
|
||||||
|
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
||||||
|
confirm: "了解",
|
||||||
|
backToHome: "ホームへ戻る",
|
||||||
|
};
|
||||||
131
src/locales/ko.ts
Normal file
131
src/locales/ko.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const koDict: Dict = {
|
||||||
|
brand: "ARK 라이브러리",
|
||||||
|
mainNav: "사이트 메뉴",
|
||||||
|
home: "홈",
|
||||||
|
all: "전체 자료",
|
||||||
|
categories: "카테고리",
|
||||||
|
latest: "최신",
|
||||||
|
official: "공식 추천",
|
||||||
|
popular: "인기 자료",
|
||||||
|
search: "검색",
|
||||||
|
searchPlaceholder: "자료 검색...",
|
||||||
|
searchPanelPlaceholder: "자료 검색...",
|
||||||
|
searchNow: "지금 검색",
|
||||||
|
searchSubmit: "검색",
|
||||||
|
cancel: "취소",
|
||||||
|
clear: "지우기",
|
||||||
|
searchPanelHint: "제목, 카테고리, 태그, 요약, 파일 유형, 본문 검색 지원",
|
||||||
|
currentTags: "사용 가능한 태그",
|
||||||
|
noTagsAvailable: "사용 가능한 태그가 없습니다.",
|
||||||
|
tagPostsTitle: "#{{tag}} 관련 자료",
|
||||||
|
noTagPosts: "이 태그가 포함된 자료가 없습니다.",
|
||||||
|
viewAll: "전체 보기",
|
||||||
|
backToTop: "맨 위로",
|
||||||
|
heroTitle: "ARK 공식 데이터베이스",
|
||||||
|
heroSub:
|
||||||
|
"ARK 라이브러리를 한곳에 모아 정리·관리하여 필요한 자료를 빠르게 찾고 커뮤니티의 성장을 함께 이끌어 갑니다.",
|
||||||
|
categorySection: "카테고리",
|
||||||
|
officialSection: "공식 추천",
|
||||||
|
latestSection: "최신 업데이트",
|
||||||
|
popularSection: "인기 자료",
|
||||||
|
preview: "미리보기",
|
||||||
|
download: "다운로드",
|
||||||
|
downloading: "다운로드 중…",
|
||||||
|
downloadOk: "다운로드 완료",
|
||||||
|
downloadFail: "다운로드 실패, 다시 시도해 주세요",
|
||||||
|
longPressImageSave: "이미지를 길게 눌러 저장",
|
||||||
|
showMore: "모두 보기",
|
||||||
|
showLess: "접기",
|
||||||
|
share: "공유",
|
||||||
|
langLabel: "언어",
|
||||||
|
admin: "관리자",
|
||||||
|
login: "로그인",
|
||||||
|
logout: "로그아웃",
|
||||||
|
email: "이메일",
|
||||||
|
password: "비밀번호",
|
||||||
|
dashboard: "대시보드",
|
||||||
|
resources: "자료",
|
||||||
|
newResource: "새 자료",
|
||||||
|
save: "저장",
|
||||||
|
title: "제목",
|
||||||
|
description: "설명",
|
||||||
|
type: "유형",
|
||||||
|
language: "언어",
|
||||||
|
category: "카테고리",
|
||||||
|
status: "상태",
|
||||||
|
public: "공개",
|
||||||
|
downloadable: "다운로드 가능",
|
||||||
|
recommended: "추천",
|
||||||
|
cover: "커버 이미지 URL",
|
||||||
|
fileUrl: "파일 URL",
|
||||||
|
externalUrl: "외부 링크",
|
||||||
|
body: "본문",
|
||||||
|
badge: "추천 배지",
|
||||||
|
published: "게시됨",
|
||||||
|
draft: "초안",
|
||||||
|
archived: "보관됨",
|
||||||
|
noResults:
|
||||||
|
"검색 결과가 없습니다. 다른 키워드로 검색하거나 카테고리를 둘러보세요.",
|
||||||
|
copyLink: "링크 복사",
|
||||||
|
related: "관련 자료",
|
||||||
|
total: "전체 자료",
|
||||||
|
views: "조회수",
|
||||||
|
downloads: "다운로드 수",
|
||||||
|
lang_zh_CN: "중국어",
|
||||||
|
lang_en: "영어",
|
||||||
|
lang_ja: "일본어",
|
||||||
|
lang_ko: "한국어",
|
||||||
|
lang_vi: "베트남어",
|
||||||
|
lang_id: "인도네시아어",
|
||||||
|
lang_ms: "말레이어",
|
||||||
|
filterAll: "전체 유형",
|
||||||
|
sortPublished: "게시일",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "음악",
|
||||||
|
type_video: "동영상",
|
||||||
|
type_image: "이미지",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "링크",
|
||||||
|
type_text: "텍스트",
|
||||||
|
type_archive: "압축 파일",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "관리자 로그인",
|
||||||
|
adminEditResource: "자료 편집",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"동영상 파일을 업로드 (MP4/WebM/MOV 등)하고 유형을 '동영상'으로 설정하면 사이트에서 자동 재생됩니다 (기본 음소거, 사용자가 음소거 해제 가능).",
|
||||||
|
adminStatTodayNew: "오늘 신규",
|
||||||
|
adminStatFavorites: "즐겨찾기",
|
||||||
|
adminMetricDownloads: "다운로드",
|
||||||
|
adminMetricFavorites: "즐겨찾기",
|
||||||
|
adminMetricViews: "조회수",
|
||||||
|
edit: "편집",
|
||||||
|
backToList: "목록으로",
|
||||||
|
sortOrderLabel: "정렬 순서",
|
||||||
|
previewUrlLabel: "미리보기 URL",
|
||||||
|
tagsCommaLabel: "태그 (쉼표로 구분)",
|
||||||
|
uploadFile: "업로드",
|
||||||
|
loading: "로딩 중…",
|
||||||
|
paginationPrev: "이전",
|
||||||
|
paginationNext: "다음",
|
||||||
|
listRange: "{{from}}–{{to}} / 총 {{total}}건",
|
||||||
|
pageIndicator: "{{c}} / {{p}} 페이지",
|
||||||
|
resourceLangFilter: "자료 언어",
|
||||||
|
filterTagClear: "태그 지우기",
|
||||||
|
filterLanguageAll: "모든 언어",
|
||||||
|
footerAdminLogin: "관리자 로그인",
|
||||||
|
adminSearchLogs: "검색 기록",
|
||||||
|
adminMetricShares: "공유",
|
||||||
|
adminSearchQuery: "검색어",
|
||||||
|
adminSearchTime: "시간",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
favorites: "내 즐겨찾기",
|
||||||
|
favoritesComingSoon: "출시 예정",
|
||||||
|
favoritesComingSoonDesc:
|
||||||
|
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
|
||||||
|
featureUnavailable: "준비 중",
|
||||||
|
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
||||||
|
confirm: "확인",
|
||||||
|
backToHome: "홈으로",
|
||||||
|
};
|
||||||
131
src/locales/ms.ts
Normal file
131
src/locales/ms.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const msDict: Dict = {
|
||||||
|
brand: "Perpustakaan ARK",
|
||||||
|
mainNav: "Menu laman",
|
||||||
|
home: "Laman utama",
|
||||||
|
all: "Semua aset",
|
||||||
|
categories: "Kategori",
|
||||||
|
latest: "Terkini",
|
||||||
|
official: "Pilihan rasmi",
|
||||||
|
popular: "Popular",
|
||||||
|
search: "Cari",
|
||||||
|
searchPlaceholder: "Cari sumber...",
|
||||||
|
searchPanelPlaceholder: "Cari aset...",
|
||||||
|
searchNow: "Cari sekarang",
|
||||||
|
searchSubmit: "Cari",
|
||||||
|
cancel: "Batal",
|
||||||
|
clear: "Kosongkan",
|
||||||
|
searchPanelHint:
|
||||||
|
"Carian menyokong tajuk, kategori, tag, ringkasan, jenis fail dan isi teks.",
|
||||||
|
currentTags: "Tag tersedia",
|
||||||
|
noTagsAvailable: "Belum ada tag tersedia.",
|
||||||
|
tagPostsTitle: "Pos berkaitan #{{tag}}",
|
||||||
|
noTagPosts: "Belum ada pos dengan tag ini.",
|
||||||
|
viewAll: "Lihat semua",
|
||||||
|
backToTop: "Kembali ke atas",
|
||||||
|
heroTitle: "Perpustakaan Rasmi ARK",
|
||||||
|
heroSub:
|
||||||
|
"Memusatkan, menyusun dan mengurus perpustakaan ARK supaya anda dapat mencari apa yang diperlukan dengan cepat dan membantu komuniti berkembang bersama.",
|
||||||
|
categorySection: "Kategori",
|
||||||
|
officialSection: "Cadangan rasmi",
|
||||||
|
latestSection: "Kemas kini terkini",
|
||||||
|
popularSection: "Aset popular",
|
||||||
|
preview: "Pratonton",
|
||||||
|
download: "Muat turun",
|
||||||
|
downloading: "Memuat turun…",
|
||||||
|
downloadOk: "Muat turun selesai",
|
||||||
|
downloadFail: "Muat turun gagal, sila cuba lagi",
|
||||||
|
longPressImageSave: "Tekan lama imej untuk simpan",
|
||||||
|
showMore: "Lihat semua",
|
||||||
|
showLess: "Tutup",
|
||||||
|
share: "Kongsi",
|
||||||
|
langLabel: "Bahasa",
|
||||||
|
admin: "Pentadbir",
|
||||||
|
login: "Log masuk",
|
||||||
|
logout: "Log keluar",
|
||||||
|
email: "E-mel",
|
||||||
|
password: "Kata laluan",
|
||||||
|
dashboard: "Papan pemuka",
|
||||||
|
resources: "Sumber",
|
||||||
|
newResource: "Sumber baharu",
|
||||||
|
save: "Simpan",
|
||||||
|
title: "Tajuk",
|
||||||
|
description: "Penerangan",
|
||||||
|
type: "Jenis",
|
||||||
|
language: "Bahasa",
|
||||||
|
category: "Kategori",
|
||||||
|
status: "Status",
|
||||||
|
public: "Awam",
|
||||||
|
downloadable: "Boleh dimuat turun",
|
||||||
|
recommended: "Pilihan",
|
||||||
|
cover: "URL imej muka",
|
||||||
|
fileUrl: "URL fail",
|
||||||
|
externalUrl: "Pautan luar",
|
||||||
|
body: "Isi teks",
|
||||||
|
badge: "Label lencana",
|
||||||
|
published: "Diterbitkan",
|
||||||
|
draft: "Draf",
|
||||||
|
archived: "Diarkibkan",
|
||||||
|
noResults: "Tiada hasil. Cuba kata kunci lain atau imbas kategori.",
|
||||||
|
copyLink: "Salin pautan",
|
||||||
|
related: "Berkaitan",
|
||||||
|
total: "Jumlah item",
|
||||||
|
views: "Tontonan",
|
||||||
|
downloads: "Muat turun",
|
||||||
|
lang_zh_CN: "Bahasa Cina",
|
||||||
|
lang_en: "Bahasa Inggeris",
|
||||||
|
lang_ja: "Bahasa Jepun",
|
||||||
|
lang_ko: "Bahasa Korea",
|
||||||
|
lang_vi: "Bahasa Vietnam",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
|
filterAll: "Semua jenis",
|
||||||
|
sortPublished: "Tarikh terbit",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "Muzik",
|
||||||
|
type_video: "Video",
|
||||||
|
type_image: "Imej",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "Pautan",
|
||||||
|
type_text: "Teks",
|
||||||
|
type_archive: "Arkib",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "Log masuk pentadbir",
|
||||||
|
adminEditResource: "Sunting sumber",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"Muat naik fail video (MP4/WebM/MOV, dll.) dan tetapkan jenis kepada Video; laman akan main automatik (asalnya senyap — pengguna boleh hidupkan bunyi).",
|
||||||
|
adminStatTodayNew: "Baharu hari ini",
|
||||||
|
adminStatFavorites: "Kegemaran",
|
||||||
|
adminMetricDownloads: "Muat turun",
|
||||||
|
adminMetricFavorites: "Kegemaran",
|
||||||
|
adminMetricViews: "Tontonan",
|
||||||
|
edit: "Sunting",
|
||||||
|
backToList: "Kembali ke senarai",
|
||||||
|
sortOrderLabel: "Susunan",
|
||||||
|
previewUrlLabel: "URL pratonton",
|
||||||
|
tagsCommaLabel: "Tag (dipisahkan koma)",
|
||||||
|
uploadFile: "Muat naik",
|
||||||
|
loading: "Memuatkan…",
|
||||||
|
paginationPrev: "Sebelum",
|
||||||
|
paginationNext: "Seterusnya",
|
||||||
|
listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
|
||||||
|
pageIndicator: "Halaman {{c}} / {{p}}",
|
||||||
|
resourceLangFilter: "Bahasa sumber",
|
||||||
|
filterTagClear: "Kosongkan tag",
|
||||||
|
filterLanguageAll: "Semua bahasa",
|
||||||
|
footerAdminLogin: "Log masuk pentadbir",
|
||||||
|
adminSearchLogs: "Log carian",
|
||||||
|
adminMetricShares: "Kongsi",
|
||||||
|
adminSearchQuery: "Kata kunci",
|
||||||
|
adminSearchTime: "Masa",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
favorites: "Kegemaran Saya",
|
||||||
|
favoritesComingSoon: "Akan Hadir",
|
||||||
|
favoritesComingSoonDesc:
|
||||||
|
"Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
|
||||||
|
featureUnavailable: "Belum tersedia",
|
||||||
|
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
||||||
|
confirm: "Faham",
|
||||||
|
backToHome: "Kembali ke Laman Utama",
|
||||||
|
};
|
||||||
1
src/locales/types.ts
Normal file
1
src/locales/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type Dict = Record<string, string>;
|
||||||
131
src/locales/vi.ts
Normal file
131
src/locales/vi.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const viDict: Dict = {
|
||||||
|
brand: "Thư viện ARK",
|
||||||
|
mainNav: "Menu trang web",
|
||||||
|
home: "Trang chủ",
|
||||||
|
all: "Tất cả tài liệu",
|
||||||
|
categories: "Danh mục",
|
||||||
|
latest: "Mới nhất",
|
||||||
|
official: "Đề xuất chính thức",
|
||||||
|
popular: "Phổ biến",
|
||||||
|
search: "Tìm kiếm",
|
||||||
|
searchPlaceholder: "Tìm tài liệu...",
|
||||||
|
searchPanelPlaceholder: "Tìm tài liệu...",
|
||||||
|
searchNow: "Tìm ngay",
|
||||||
|
searchSubmit: "Tìm kiếm",
|
||||||
|
cancel: "Hủy",
|
||||||
|
clear: "Xóa",
|
||||||
|
searchPanelHint:
|
||||||
|
"Hỗ trợ tìm theo tiêu đề, danh mục, thẻ, tóm tắt, loại tệp và nội dung.",
|
||||||
|
currentTags: "Thẻ hiện có",
|
||||||
|
noTagsAvailable: "Chưa có thẻ nào.",
|
||||||
|
tagPostsTitle: "Tài liệu liên quan #{{tag}}",
|
||||||
|
noTagPosts: "Chưa có tài liệu nào với thẻ này.",
|
||||||
|
viewAll: "Xem tất cả",
|
||||||
|
backToTop: "Lên đầu trang",
|
||||||
|
heroTitle: "Thư viện chính thức ARK",
|
||||||
|
heroSub:
|
||||||
|
"Tập trung, phân loại và quản lý thư viện ARK để bạn nhanh chóng tìm thấy tài nguyên cần thiết và thúc đẩy sự phát triển của cộng đồng.",
|
||||||
|
categorySection: "Danh mục",
|
||||||
|
officialSection: "Đề xuất chính thức",
|
||||||
|
latestSection: "Cập nhật mới",
|
||||||
|
popularSection: "Tài liệu phổ biến",
|
||||||
|
preview: "Xem trước",
|
||||||
|
download: "Tải xuống",
|
||||||
|
downloading: "Đang tải xuống…",
|
||||||
|
downloadOk: "Tải xuống hoàn tất",
|
||||||
|
downloadFail: "Tải xuống thất bại, vui lòng thử lại",
|
||||||
|
longPressImageSave: "Nhấn giữ ảnh để lưu",
|
||||||
|
showMore: "Xem tất cả",
|
||||||
|
showLess: "Thu gọn",
|
||||||
|
share: "Chia sẻ",
|
||||||
|
langLabel: "Ngôn ngữ",
|
||||||
|
admin: "Quản trị",
|
||||||
|
login: "Đăng nhập",
|
||||||
|
logout: "Đăng xuất",
|
||||||
|
email: "Email",
|
||||||
|
password: "Mật khẩu",
|
||||||
|
dashboard: "Bảng điều khiển",
|
||||||
|
resources: "Tài liệu",
|
||||||
|
newResource: "Tài liệu mới",
|
||||||
|
save: "Lưu",
|
||||||
|
title: "Tiêu đề",
|
||||||
|
description: "Mô tả",
|
||||||
|
type: "Loại",
|
||||||
|
language: "Ngôn ngữ",
|
||||||
|
category: "Danh mục",
|
||||||
|
status: "Trạng thái",
|
||||||
|
public: "Công khai",
|
||||||
|
downloadable: "Có thể tải xuống",
|
||||||
|
recommended: "Nổi bật",
|
||||||
|
cover: "URL ảnh bìa",
|
||||||
|
fileUrl: "URL tệp",
|
||||||
|
externalUrl: "Liên kết bên ngoài",
|
||||||
|
body: "Nội dung",
|
||||||
|
badge: "Nhãn đề xuất",
|
||||||
|
published: "Đã xuất bản",
|
||||||
|
draft: "Bản nháp",
|
||||||
|
archived: "Đã lưu trữ",
|
||||||
|
noResults: "Không có kết quả. Hãy thử từ khóa khác hoặc duyệt danh mục.",
|
||||||
|
copyLink: "Sao chép liên kết",
|
||||||
|
related: "Tài liệu liên quan",
|
||||||
|
total: "Tổng số tài liệu",
|
||||||
|
views: "Lượt xem",
|
||||||
|
downloads: "Lượt tải",
|
||||||
|
lang_zh_CN: "Tiếng Trung",
|
||||||
|
lang_en: "Tiếng Anh",
|
||||||
|
lang_ja: "Tiếng Nhật",
|
||||||
|
lang_ko: "Tiếng Hàn",
|
||||||
|
lang_vi: "Tiếng Việt",
|
||||||
|
lang_id: "Tiếng Indonesia",
|
||||||
|
lang_ms: "Tiếng Mã Lai",
|
||||||
|
filterAll: "Tất cả loại",
|
||||||
|
sortPublished: "Ngày xuất bản",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "Âm nhạc",
|
||||||
|
type_video: "Video",
|
||||||
|
type_image: "Hình ảnh",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "Liên kết",
|
||||||
|
type_text: "Văn bản",
|
||||||
|
type_archive: "Tệp nén",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "Đăng nhập quản trị",
|
||||||
|
adminEditResource: "Chỉnh sửa tài liệu",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"Tải lên tệp video (MP4/WebM/MOV, v.v.) và đặt loại là Video; trang web sẽ tự động phát (mặc định tắt tiếng, người dùng có thể bật).",
|
||||||
|
adminStatTodayNew: "Mới hôm nay",
|
||||||
|
adminStatFavorites: "Yêu thích",
|
||||||
|
adminMetricDownloads: "Lượt tải",
|
||||||
|
adminMetricFavorites: "Yêu thích",
|
||||||
|
adminMetricViews: "Lượt xem",
|
||||||
|
edit: "Chỉnh sửa",
|
||||||
|
backToList: "Quay lại danh sách",
|
||||||
|
sortOrderLabel: "Thứ tự sắp xếp",
|
||||||
|
previewUrlLabel: "URL xem trước",
|
||||||
|
tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)",
|
||||||
|
uploadFile: "Tải lên",
|
||||||
|
loading: "Đang tải…",
|
||||||
|
paginationPrev: "Trước",
|
||||||
|
paginationNext: "Sau",
|
||||||
|
listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
|
||||||
|
pageIndicator: "Trang {{c}} / {{p}}",
|
||||||
|
resourceLangFilter: "Ngôn ngữ tài liệu",
|
||||||
|
filterTagClear: "Xóa thẻ",
|
||||||
|
filterLanguageAll: "Tất cả ngôn ngữ",
|
||||||
|
footerAdminLogin: "Đăng nhập quản trị",
|
||||||
|
adminSearchLogs: "Lịch sử tìm kiếm",
|
||||||
|
adminMetricShares: "Chia sẻ",
|
||||||
|
adminSearchQuery: "Từ khóa",
|
||||||
|
adminSearchTime: "Thời gian",
|
||||||
|
adminSearchId: "ID",
|
||||||
|
favorites: "Yêu thích của tôi",
|
||||||
|
favoritesComingSoon: "Sắp ra mắt",
|
||||||
|
favoritesComingSoonDesc:
|
||||||
|
"Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.",
|
||||||
|
featureUnavailable: "Chưa khả dụng",
|
||||||
|
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
||||||
|
confirm: "Đã hiểu",
|
||||||
|
backToHome: "Về trang chủ",
|
||||||
|
};
|
||||||
129
src/locales/zh-CN.ts
Normal file
129
src/locales/zh-CN.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Dict } from "./types";
|
||||||
|
|
||||||
|
export const zhDict: Dict = {
|
||||||
|
brand: "ARK 资料库",
|
||||||
|
mainNav: "网站导航",
|
||||||
|
home: "首页",
|
||||||
|
all: "全部资料",
|
||||||
|
categories: "资料分类",
|
||||||
|
latest: "最新更新",
|
||||||
|
official: "官方推荐",
|
||||||
|
popular: "热门资料",
|
||||||
|
search: "搜索",
|
||||||
|
searchPlaceholder: "搜索资料...",
|
||||||
|
searchPanelPlaceholder: "搜索资料...",
|
||||||
|
searchNow: "立即搜索资料",
|
||||||
|
searchSubmit: "搜索",
|
||||||
|
cancel: "取消",
|
||||||
|
clear: "清除",
|
||||||
|
searchPanelHint: "支持搜索 标题・分类・标签・简介・文件类型・正文",
|
||||||
|
currentTags: "现有标签",
|
||||||
|
noTagsAvailable: "暂无可选择的标签。",
|
||||||
|
tagPostsTitle: "#{{tag}} 相关资料",
|
||||||
|
noTagPosts: "暂时找不到带有此标签的资料。",
|
||||||
|
viewAll: "查看全部",
|
||||||
|
backToTop: "回到顶部",
|
||||||
|
heroTitle: "ARK 官方数据库",
|
||||||
|
heroSub:
|
||||||
|
"集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
|
||||||
|
categorySection: "资料分类",
|
||||||
|
officialSection: "官方推荐",
|
||||||
|
latestSection: "最新更新",
|
||||||
|
popularSection: "热门资料",
|
||||||
|
preview: "预览",
|
||||||
|
download: "下载",
|
||||||
|
downloading: "下载中…",
|
||||||
|
downloadOk: "下载完成",
|
||||||
|
downloadFail: "下载失败,请重试",
|
||||||
|
longPressImageSave: "长按图片保存到相册",
|
||||||
|
showMore: "展开全部",
|
||||||
|
showLess: "收起全部",
|
||||||
|
share: "分享",
|
||||||
|
langLabel: "语言",
|
||||||
|
admin: "后台",
|
||||||
|
login: "登录",
|
||||||
|
logout: "退出",
|
||||||
|
email: "邮箱",
|
||||||
|
password: "密码",
|
||||||
|
dashboard: "仪表盘",
|
||||||
|
resources: "资料管理",
|
||||||
|
newResource: "新增资料",
|
||||||
|
save: "保存",
|
||||||
|
title: "标题",
|
||||||
|
description: "简介",
|
||||||
|
type: "类型",
|
||||||
|
language: "语言",
|
||||||
|
category: "分类",
|
||||||
|
status: "状态",
|
||||||
|
public: "公开",
|
||||||
|
downloadable: "可下载",
|
||||||
|
recommended: "首页推荐",
|
||||||
|
cover: "封面图 URL",
|
||||||
|
fileUrl: "文件 URL",
|
||||||
|
externalUrl: "外部链接",
|
||||||
|
body: "文案内容",
|
||||||
|
badge: "推荐标签",
|
||||||
|
published: "已发布",
|
||||||
|
draft: "草稿",
|
||||||
|
archived: "归档",
|
||||||
|
noResults: "找不到符合的资料,请换个关键字或浏览分类。",
|
||||||
|
copyLink: "复制链接",
|
||||||
|
related: "相关资料",
|
||||||
|
total: "总资料",
|
||||||
|
views: "浏览",
|
||||||
|
downloads: "下载",
|
||||||
|
lang_zh_CN: "中文",
|
||||||
|
lang_en: "English",
|
||||||
|
lang_ja: "日本語",
|
||||||
|
lang_ko: "한국어",
|
||||||
|
lang_vi: "Tiếng Việt",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
|
filterAll: "全部",
|
||||||
|
sortPublished: "发布时间",
|
||||||
|
type_ppt: "PPT",
|
||||||
|
type_music: "音乐",
|
||||||
|
type_video: "视频",
|
||||||
|
type_image: "图片",
|
||||||
|
type_pdf: "PDF",
|
||||||
|
type_link: "链接",
|
||||||
|
type_text: "文字",
|
||||||
|
type_archive: "压缩包",
|
||||||
|
type_zip: "ZIP",
|
||||||
|
adminLoginTitle: "管理后台登录",
|
||||||
|
adminEditResource: "编辑资料",
|
||||||
|
adminVideoFileHint:
|
||||||
|
"上传视频文件(MP4/WebM/MOV 等),类型请选择「视频」;保存后前台自动播放(默认静音,可点喇叭开声音)。",
|
||||||
|
adminStatTodayNew: "今日新增",
|
||||||
|
adminStatFavorites: "收藏",
|
||||||
|
adminMetricDownloads: "下载",
|
||||||
|
adminMetricFavorites: "收藏",
|
||||||
|
adminMetricViews: "浏览",
|
||||||
|
edit: "编辑",
|
||||||
|
backToList: "返回列表",
|
||||||
|
sortOrderLabel: "排序权重",
|
||||||
|
previewUrlLabel: "预览网址",
|
||||||
|
tagsCommaLabel: "标签(逗号分隔)",
|
||||||
|
uploadFile: "上传文件",
|
||||||
|
loading: "加载中…",
|
||||||
|
paginationPrev: "上一页",
|
||||||
|
paginationNext: "下一页",
|
||||||
|
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
||||||
|
pageIndicator: "{{c}} / {{p}} 页",
|
||||||
|
resourceLangFilter: "资料语言",
|
||||||
|
filterTagClear: "清除标签",
|
||||||
|
filterLanguageAll: "全部语言",
|
||||||
|
footerAdminLogin: "管理员登录",
|
||||||
|
adminSearchLogs: "搜索记录",
|
||||||
|
adminMetricShares: "分享",
|
||||||
|
adminSearchQuery: "查询词",
|
||||||
|
adminSearchTime: "时间",
|
||||||
|
adminSearchId: "编号",
|
||||||
|
favorites: "我的收藏",
|
||||||
|
favoritesComingSoon: "功能即将推出",
|
||||||
|
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
||||||
|
featureUnavailable: "未开放",
|
||||||
|
featureUnavailableDesc: "该功能暂未开放。",
|
||||||
|
confirm: "知道了",
|
||||||
|
backToHome: "返回首页",
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { CategoryIcon } from "../../components/CategoryIcon";
|
|||||||
import { useSetPageTitle } from "../../components/PageTitleContext";
|
import { useSetPageTitle } from "../../components/PageTitleContext";
|
||||||
import { Skeleton } from "../../components/Skeleton";
|
import { Skeleton } from "../../components/Skeleton";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
import { Reveal } from "../../motion";
|
import { Reveal } from "../../motion";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function figmaCategoryRank(category: Category): number {
|
|||||||
|
|
||||||
export function CategoriesPage() {
|
export function CategoriesPage() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
useSetPageTitle(t("categories"));
|
useSetPageTitle(t("categories"));
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -89,7 +91,7 @@ export function CategoriesPage() {
|
|||||||
className="h-[88px]"
|
className="h-[88px]"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/category/${encodeURIComponent(category.slug)}`}
|
to={lp(`/category/${encodeURIComponent(category.slug)}`)}
|
||||||
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RecommendedCard } from "../../components/RecommendedCard";
|
|||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
import { sourceLanguageQuery } from "../../i18nLanguages";
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
||||||
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +43,7 @@ function figmaCategoryRank(category: Category): number {
|
|||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
||||||
@@ -298,7 +300,7 @@ export function Home() {
|
|||||||
{page.map((c) => (
|
{page.map((c) => (
|
||||||
<Link
|
<Link
|
||||||
key={c.id}
|
key={c.id}
|
||||||
to={`/category/${encodeURIComponent(c.slug)}`}
|
to={lp(`/category/${encodeURIComponent(c.slug)}`)}
|
||||||
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
@@ -350,7 +352,7 @@ export function Home() {
|
|||||||
{figmaOrderedCategories.map((c, index) => (
|
{figmaOrderedCategories.map((c, index) => (
|
||||||
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
|
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
|
||||||
<Link
|
<Link
|
||||||
to={`/category/${encodeURIComponent(c.slug)}`}
|
to={lp(`/category/${encodeURIComponent(c.slug)}`)}
|
||||||
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { getJSON } from "../../api";
|
import { getJSON } from "../../api";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
import { MOCK_POSTS } from "../../mocks/mockPosts";
|
import { MOCK_POSTS } from "../../mocks/mockPosts";
|
||||||
import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream";
|
import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream";
|
||||||
import type { Post } from "../../types/post";
|
import type { Post } from "../../types/post";
|
||||||
@@ -10,17 +11,18 @@ export function PostRedirect() {
|
|||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
navigate("/browse", { replace: true });
|
navigate(lp("/browse"), { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (POST_STREAM_USES_MOCK) {
|
if (POST_STREAM_USES_MOCK) {
|
||||||
const post = MOCK_POSTS.find((p) => p.id === id);
|
const post = MOCK_POSTS.find((p) => p.id === id);
|
||||||
navigate(
|
navigate(
|
||||||
post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse",
|
lp(post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse"),
|
||||||
{
|
{
|
||||||
replace: true,
|
replace: true,
|
||||||
},
|
},
|
||||||
@@ -32,12 +34,12 @@ export function PostRedirect() {
|
|||||||
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
)
|
)
|
||||||
.then((post) => {
|
.then((post) => {
|
||||||
navigate(`/browse?post=${encodeURIComponent(post.id)}`, {
|
navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`), {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => navigate("/browse", { replace: true }));
|
.catch(() => navigate(lp("/browse"), { replace: true }));
|
||||||
}, [id, lang, navigate]);
|
}, [id, lang, navigate, lp]);
|
||||||
|
|
||||||
return <div className="text-neutral-400">…</div>;
|
return <div className="text-neutral-400">…</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Navigate, useSearchParams } from "react-router-dom";
|
import { Navigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
|
|
||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
const [sp] = useSearchParams();
|
const [sp] = useSearchParams();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const query = sp.toString();
|
const query = sp.toString();
|
||||||
return <Navigate to={`/browse${query ? `?${query}` : ""}`} replace />;
|
return <Navigate to={lp(`/browse${query ? `?${query}` : ""}`)} replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type Attachment = {
|
|||||||
posterUrl?: string;
|
posterUrl?: string;
|
||||||
thumbUrl?: string;
|
thumbUrl?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
/** Optional 540p/mobile-friendly preview video. Downloads still use the original endpoint. */
|
||||||
|
mobilePreviewUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
src/useLocalizedPath.ts
Normal file
13
src/useLocalizedPath.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useI18n } from "./i18n";
|
||||||
|
import { localizePath } from "./languageRoutes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stable `(path) => localized path` function bound to the current
|
||||||
|
* UI language. Use this anywhere a `<Link to>` or `navigate()` target needs to
|
||||||
|
* preserve the active language prefix (e.g. `/malay/browse`).
|
||||||
|
*/
|
||||||
|
export function useLocalizedPath() {
|
||||||
|
const { lang } = useI18n();
|
||||||
|
return useCallback((path: string) => localizePath(path, lang), [lang]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user