Merge branch 'main' into terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 31s
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 31s
# Conflicts: # src/components/messageStream/MessageStream.tsx
This commit is contained in:
27
src/components/messageStream/AttachmentFilenameLabel.tsx
Normal file
27
src/components/messageStream/AttachmentFilenameLabel.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Attachment } from "../../types/post";
|
||||
import { filenameWithExtension } from "./utils/filenameDisplay";
|
||||
|
||||
/**
|
||||
* Bottom-left overlay that surfaces an attachment's filename on top of an
|
||||
* image/video card. Used for posts in the 官方物料 (`official-assets`)
|
||||
* category, where editors rely on the filename to identify the original asset
|
||||
* at a glance.
|
||||
*/
|
||||
export function AttachmentFilenameLabel({
|
||||
attachment,
|
||||
className = "absolute bottom-2 left-2",
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
className?: string;
|
||||
}) {
|
||||
const display = filenameWithExtension(attachment.filename, attachment.mime);
|
||||
if (!display) return null;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none z-10 max-w-[calc(100%-1rem)] truncate rounded-full bg-black/80 px-2.5 py-1 text-[12px] font-medium text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md ${className}`}
|
||||
title={display}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { typeFilterLabel } from "../../resourceTypeLabels";
|
||||
|
||||
@@ -19,13 +19,20 @@ export type FilterChipsProps = {
|
||||
onTypeChange: (next: string) => void;
|
||||
};
|
||||
|
||||
let lastScrollLeft = 0;
|
||||
|
||||
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Let a mouse wheel scroll the row horizontally when it overflows — desktop
|
||||
// mice have no horizontal wheel and the scrollbar is hidden, so otherwise the
|
||||
// last filters are unreachable. Touch/trackpad scroll natively.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (lastScrollLeft > 0 && el.scrollLeft !== lastScrollLeft) {
|
||||
el.scrollLeft = lastScrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -38,13 +45,32 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const save = () => {
|
||||
lastScrollLeft = el.scrollLeft;
|
||||
};
|
||||
const saveDeferred = () => {
|
||||
window.requestAnimationFrame(save);
|
||||
};
|
||||
el.addEventListener("touchend", saveDeferred, { passive: true });
|
||||
el.addEventListener("pointerup", saveDeferred, { passive: true });
|
||||
el.addEventListener("wheel", saveDeferred, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchend", saveDeferred);
|
||||
el.removeEventListener("pointerup", saveDeferred);
|
||||
el.removeEventListener("wheel", saveDeferred);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
[
|
||||
"relative flex h-[52px] shrink-0 items-center whitespace-nowrap px-3 pb-4 pt-3 text-[15px] leading-6 outline-none transition-colors md:h-auto md:px-1 md:py-3 md:leading-none",
|
||||
"border-b-0 md:border-b-2",
|
||||
active
|
||||
? "border-ark-gold font-medium text-ark-gold"
|
||||
: "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400",
|
||||
: "border-transparent text-[#97989A] [@media(hover:hover)]:hover:text-ark-gold/80 md:text-neutral-400",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { postJSON } from "../../api";
|
||||
import { useI18n } from "../../i18n";
|
||||
import type { PostScope } from "../../types/post";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { getJSON, postJSON } from "../../api";
|
||||
import { langQuery, useI18n } from "../../i18n";
|
||||
import type { Post, PostScope } from "../../types/post";
|
||||
import { Reveal } from "../../motion";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { FilterChips } from "./FilterChips";
|
||||
@@ -32,7 +33,6 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||
usePostStream(params);
|
||||
const { ensureFavoriteIds } = useFavorites();
|
||||
const groups = useGroupedByDay(items, lang);
|
||||
const retryLabel = t("retry");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,6 +85,27 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
? hash.slice("#post-".length)
|
||||
: "";
|
||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||
const [resolvedTargetPost, setResolvedTargetPost] = useState<Post | null>(
|
||||
null,
|
||||
);
|
||||
const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false);
|
||||
const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false);
|
||||
const targetAlreadyInBaseItems = useMemo(
|
||||
() =>
|
||||
!!queryTargetPostId &&
|
||||
items.some((post) => post.id === queryTargetPostId),
|
||||
[items, queryTargetPostId],
|
||||
);
|
||||
const streamItems = useMemo(() => {
|
||||
if (
|
||||
resolvedTargetPost &&
|
||||
!items.some((post) => post.id === resolvedTargetPost.id)
|
||||
) {
|
||||
return [resolvedTargetPost, ...items];
|
||||
}
|
||||
return items;
|
||||
}, [items, resolvedTargetPost]);
|
||||
const groups = useGroupedByDay(streamItems, lang);
|
||||
// Lock only engages while we are actively running the smooth-scroll animation
|
||||
// — not during the wait/pagination phase — so the page never feels frozen
|
||||
// before the bubble exists.
|
||||
@@ -119,10 +140,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
// Mark when first real content becomes visible (skeletons gone, items in).
|
||||
// Captured per-target via the reset above so a later navigation re-measures.
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !isLoading && firstContentAtRef.current === null) {
|
||||
if (
|
||||
streamItems.length > 0 &&
|
||||
!isLoading &&
|
||||
firstContentAtRef.current === null
|
||||
) {
|
||||
firstContentAtRef.current = performance.now();
|
||||
}
|
||||
}, [items.length, isLoading]);
|
||||
}, [streamItems.length, isLoading]);
|
||||
|
||||
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
|
||||
// smooth-scroll positioning from the top of the stream, so the user sees a
|
||||
@@ -134,6 +159,51 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, [queryTargetPostId]);
|
||||
|
||||
// Search result clicks can target very old posts that are nowhere near the
|
||||
// first paginated /browse page. Do not make the user wait while the stream
|
||||
// loads page after page; fetch the target post directly and inject it at the
|
||||
// top so it can render and be highlighted immediately. The normal stream
|
||||
// still loads underneath for context / scrolling.
|
||||
useEffect(() => {
|
||||
if (!queryTargetPostId) {
|
||||
setResolvedTargetPost(null);
|
||||
setIsFetchingTargetPost(false);
|
||||
setTargetPostFetchFailed(false);
|
||||
return;
|
||||
}
|
||||
if (targetAlreadyInBaseItems) {
|
||||
setResolvedTargetPost(null);
|
||||
setIsFetchingTargetPost(false);
|
||||
setTargetPostFetchFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsFetchingTargetPost(true);
|
||||
setTargetPostFetchFailed(false);
|
||||
getJSON<Post>(
|
||||
`/api/posts/${encodeURIComponent(queryTargetPostId)}?lang=${encodeURIComponent(
|
||||
langQuery(lang),
|
||||
)}`,
|
||||
)
|
||||
.then((post) => {
|
||||
if (cancelled) return;
|
||||
setResolvedTargetPost(post);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setResolvedTargetPost(null);
|
||||
setTargetPostFetchFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsFetchingTargetPost(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lang, queryTargetPostId, targetAlreadyInBaseItems]);
|
||||
|
||||
useEffect(() => clearTargetScrollTimers, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -267,7 +337,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
}
|
||||
if (hasMore && !isLoading) loadMore();
|
||||
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
||||
}, [targetPostId, items, hasMore, isLoading, error, loadMore]);
|
||||
}, [targetPostId, streamItems, hasMore, isLoading, error, loadMore]);
|
||||
|
||||
const updateParam = (key: string, value: string) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
@@ -276,7 +346,28 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
setSp(n, { replace: true });
|
||||
};
|
||||
|
||||
const isInitialLoad = isLoading && items.length === 0;
|
||||
const isInitialLoad = isLoading && streamItems.length === 0;
|
||||
|
||||
// When the user arrives via /browse?post=<id> (typically from search or a
|
||||
// banner) and the target post lives deep in the stream, pagination has to
|
||||
// keep loading older pages until it surfaces. Show an explicit "finding
|
||||
// your post" indicator so the user knows we're actively searching for
|
||||
// their specific post, not just lazily loading the feed.
|
||||
const targetInLoadedItems =
|
||||
!!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId);
|
||||
const isSearchingDeepTarget =
|
||||
!!queryTargetPostId &&
|
||||
!targetInLoadedItems &&
|
||||
!error &&
|
||||
(isFetchingTargetPost || hasMore || isLoading);
|
||||
const targetNotFoundInStream =
|
||||
!!queryTargetPostId &&
|
||||
!targetInLoadedItems &&
|
||||
!error &&
|
||||
targetPostFetchFailed &&
|
||||
!hasMore &&
|
||||
!isLoading &&
|
||||
streamItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||
@@ -290,6 +381,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
||||
{isSearchingDeepTarget ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mx-auto flex w-fit max-w-full items-center gap-2 rounded-full border border-ark-gold/40 bg-ark-gold/10 px-4 py-2 text-sm text-ark-gold shadow-sm"
|
||||
>
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
|
||||
<span>{t("searchingForPost")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{targetNotFoundInStream ? (
|
||||
<div
|
||||
role="status"
|
||||
className="mx-auto w-fit max-w-full rounded-full border border-yellow-700/40 bg-yellow-950/30 px-4 py-2 text-center text-sm text-yellow-200"
|
||||
>
|
||||
{t("postNotFound")}
|
||||
</div>
|
||||
) : null}
|
||||
{isInitialLoad ? (
|
||||
<>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
@@ -317,7 +426,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && !error && items.length === 0 ? (
|
||||
{!isLoading && !error && streamItems.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-400">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
@@ -331,7 +440,9 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
<span className="break-words">{t("loadMoreFailed")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (items.length === 0 ? reset() : loadMore())}
|
||||
onClick={() =>
|
||||
streamItems.length === 0 ? reset() : loadMore()
|
||||
}
|
||||
className="shrink-0 self-start rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500 sm:self-auto"
|
||||
>
|
||||
{retryLabel}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useI18n } from "../../../i18n";
|
||||
import type { Post } from "../../../types/post";
|
||||
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
import { useImageRatios } from "../hooks/useImageRatios";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
@@ -19,6 +20,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
const text = postDisplayText(post, lang);
|
||||
const visible = images.slice(0, MAX_VISIBLE);
|
||||
const extra = images.length - MAX_VISIBLE;
|
||||
const showFilename = post.categorySlug === "official-assets";
|
||||
|
||||
const sources = visible.map(
|
||||
(att) => att.thumbnailUrl ?? att.thumbUrl ?? att.url,
|
||||
@@ -90,6 +92,9 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
adaptive
|
||||
/>
|
||||
) : null}
|
||||
{!isLastSlot && showFilename ? (
|
||||
<AttachmentFilenameLabel attachment={att} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,5 +4,11 @@ import { SingleImageFrame } from "./SingleImageFrame";
|
||||
export function ImageBubble({ post }: { post: Post }) {
|
||||
const att = post.attachments[0];
|
||||
if (!att) return null;
|
||||
return <SingleImageFrame postId={post.id} attachment={att} />;
|
||||
return (
|
||||
<SingleImageFrame
|
||||
postId={post.id}
|
||||
attachment={att}
|
||||
showFilename={post.categorySlug === "official-assets"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||
if (!att) return null;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SingleImageFrame postId={post.id} attachment={att} text={text} />
|
||||
<SingleImageFrame
|
||||
postId={post.id}
|
||||
attachment={att}
|
||||
text={text}
|
||||
showFilename={post.categorySlug === "official-assets"}
|
||||
/>
|
||||
{text ? (
|
||||
<CollapsibleText
|
||||
wrapperClassName="px-4 pt-3"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { AdaptiveImageFrame } from "./AdaptiveImageFrame";
|
||||
|
||||
@@ -11,10 +12,13 @@ export function SingleImageFrame({
|
||||
postId,
|
||||
attachment,
|
||||
text,
|
||||
showFilename = false,
|
||||
}: {
|
||||
postId: string;
|
||||
attachment: Attachment;
|
||||
text?: string;
|
||||
/** Show the source filename pinned bottom-left (used for 官方物料 cards). */
|
||||
showFilename?: boolean;
|
||||
}) {
|
||||
const { openLightbox } = useLightbox();
|
||||
return (
|
||||
@@ -29,6 +33,9 @@ export function SingleImageFrame({
|
||||
ariaLabel="View image"
|
||||
>
|
||||
<AttachmentDownloadPill postId={postId} attachment={attachment} />
|
||||
{showFilename ? (
|
||||
<AttachmentFilenameLabel attachment={attachment} />
|
||||
) : null}
|
||||
</AdaptiveImageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Attachment, Post } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
|
||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||
import {
|
||||
useShouldUseMobilePreview,
|
||||
@@ -57,12 +58,15 @@ function VideoAttachmentCard({
|
||||
compact = false,
|
||||
overlayCount,
|
||||
onMoreClick,
|
||||
showFilename = false,
|
||||
}: {
|
||||
postId: string;
|
||||
attachment: Attachment;
|
||||
compact?: boolean;
|
||||
overlayCount?: number;
|
||||
onMoreClick?: () => void;
|
||||
/** Show the source filename pinned bottom-left (used for 官方物料 cards). */
|
||||
showFilename?: boolean;
|
||||
}) {
|
||||
const { openVideo } = useVideoPlayer();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -117,6 +121,9 @@ function VideoAttachmentCard({
|
||||
leadingLabel={duration}
|
||||
/>
|
||||
) : null}
|
||||
{!overlayCount && showFilename ? (
|
||||
<AttachmentFilenameLabel attachment={attachment} />
|
||||
) : null}
|
||||
{overlayCount ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -324,6 +331,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const videos = post.attachments.filter(isVideoAttachment);
|
||||
const text = postDisplayText(post, lang);
|
||||
const showFilename = post.categorySlug === "official-assets";
|
||||
if (!videos.length) return null;
|
||||
|
||||
if (videos.length >= 2) {
|
||||
@@ -349,6 +357,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
||||
compact
|
||||
overlayCount={isLastSlot ? extra : undefined}
|
||||
onMoreClick={() => setListOpen(true)}
|
||||
showFilename={showFilename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -379,7 +388,11 @@ export function VideoBubble({ post }: { post: Post }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<VideoAttachmentCard postId={post.id} attachment={videos[0]} />
|
||||
<VideoAttachmentCard
|
||||
postId={post.id}
|
||||
attachment={videos[0]}
|
||||
showFilename={showFilename}
|
||||
/>
|
||||
{text ? (
|
||||
<CollapsibleText
|
||||
wrapperClassName="px-4 pt-3"
|
||||
|
||||
@@ -336,7 +336,14 @@ export function PublicLayout() {
|
||||
});
|
||||
}
|
||||
};
|
||||
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
|
||||
// Routes that render a full-bleed asset stream and manage their own inner
|
||||
// width / padding via `MessageStream`. Both 全部资料 (/browse) and the
|
||||
// per-category view (/category/<slug>) reuse the same component, so they
|
||||
// need the same zero outer padding here — otherwise the category page's
|
||||
// bubbles render narrower than the all-resources page.
|
||||
const strippedPath = stripLangPrefix(pathname);
|
||||
const footerInContentFlow =
|
||||
strippedPath === "/browse" || strippedPath.startsWith("/category/");
|
||||
// Current page name shown in the header brand slot (falls back to the brand).
|
||||
const pageTitle = usePageTitle();
|
||||
|
||||
|
||||
@@ -129,6 +129,9 @@ export const enDict: Dict = {
|
||||
loadMoreFailed:
|
||||
"Couldn't load more posts. Check your connection and try again.",
|
||||
retry: "Retry",
|
||||
searchingForPost: "Finding your post… loading older entries, please wait.",
|
||||
postNotFound:
|
||||
"Couldn’t find this post in the current view. It may have been removed.",
|
||||
paginationPrev: "Previous",
|
||||
paginationNext: "Next",
|
||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||
|
||||
@@ -129,6 +129,10 @@ export const idDict: Dict = {
|
||||
loadMoreFailed:
|
||||
"Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.",
|
||||
retry: "Coba lagi",
|
||||
searchingForPost:
|
||||
"Mencari postingan Anda… memuat postingan lama, mohon tunggu.",
|
||||
postNotFound:
|
||||
"Postingan ini tidak ditemukan di daftar saat ini. Mungkin sudah dihapus.",
|
||||
paginationPrev: "Sebelumnya",
|
||||
paginationNext: "Berikutnya",
|
||||
listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
|
||||
|
||||
@@ -130,6 +130,10 @@ export const jaDict: Dict = {
|
||||
loadMoreFailed:
|
||||
"追加の読み込みに失敗しました。接続を確認してやり直してください。",
|
||||
retry: "再試行",
|
||||
searchingForPost:
|
||||
"投稿を検索中…古い投稿を読み込んでいます。しばらくお待ちください。",
|
||||
postNotFound:
|
||||
"現在のリストでこの投稿が見つかりません。削除された可能性があります。",
|
||||
paginationPrev: "前へ",
|
||||
paginationNext: "次へ",
|
||||
listRange: "{{from}}–{{to}} / 全 {{total}} 件",
|
||||
|
||||
@@ -128,6 +128,10 @@ export const koDict: Dict = {
|
||||
loading: "로딩 중…",
|
||||
loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
||||
retry: "다시 시도",
|
||||
searchingForPost:
|
||||
"게시물을 찾는 중… 이전 게시물을 불러오고 있습니다. 잠시만 기다려주세요.",
|
||||
postNotFound:
|
||||
"현재 목록에서 이 게시물을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
|
||||
paginationPrev: "이전",
|
||||
paginationNext: "다음",
|
||||
listRange: "{{from}}–{{to}} / 총 {{total}}건",
|
||||
|
||||
@@ -128,6 +128,9 @@ export const msDict: Dict = {
|
||||
loading: "Memuatkan…",
|
||||
loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.",
|
||||
retry: "Cuba lagi",
|
||||
searchingForPost: "Mencari pos anda… memuat pos lama, sila tunggu.",
|
||||
postNotFound:
|
||||
"Pos ini tidak ditemui dalam senarai semasa. Mungkin telah dipadam.",
|
||||
paginationPrev: "Sebelum",
|
||||
paginationNext: "Seterusnya",
|
||||
listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
|
||||
|
||||
@@ -128,6 +128,9 @@ export const viDict: Dict = {
|
||||
loading: "Đang tải…",
|
||||
loadMoreFailed: "Không thể tải thêm bài. Hãy kiểm tra kết nối và thử lại.",
|
||||
retry: "Thử lại",
|
||||
searchingForPost: "Đang tìm bài viết… tải thêm bài cũ, vui lòng đợi.",
|
||||
postNotFound:
|
||||
"Không tìm thấy bài này trong danh sách hiện tại. Bài có thể đã bị xóa.",
|
||||
paginationPrev: "Trước",
|
||||
paginationNext: "Sau",
|
||||
listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
|
||||
|
||||
@@ -126,6 +126,8 @@ export const zhDict: Dict = {
|
||||
loading: "加载中…",
|
||||
loadMoreFailed: "加载更多资料失败,请检查网络后重试。",
|
||||
retry: "重试",
|
||||
searchingForPost: "正在查找您的帖子,请稍等…",
|
||||
postNotFound: "在当前列表中找不到这个帖子,可能已被移除。",
|
||||
paginationPrev: "上一页",
|
||||
paginationNext: "下一页",
|
||||
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
||||
|
||||
Reference in New Issue
Block a user