feat: wire public posts api
This commit is contained in:
@@ -44,6 +44,11 @@ export async function postJSON<T>(
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postNoBody(path: string): Promise<void> {
|
||||||
|
const res = await fetch(`${apiBase}${path}`, { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
export async function putJSON<T>(
|
export async function putJSON<T>(
|
||||||
path: string,
|
path: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { Resource } from "../api";
|
import type { Resource } from "../api";
|
||||||
import { assetUrl, postJSON } from "../api";
|
import { assetUrl, postJSON, postNoBody } from "../api";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
@@ -14,11 +14,16 @@ function isPlaceholderAsset(path: string | undefined | null) {
|
|||||||
const CARD_CLASS =
|
const CARD_CLASS =
|
||||||
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
|
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
|
||||||
|
|
||||||
|
type RecommendedResource = Resource & {
|
||||||
|
downloadPostId?: string;
|
||||||
|
downloadAttachmentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function RecommendedCard({
|
export function RecommendedCard({
|
||||||
r,
|
r,
|
||||||
visualIndex = 0,
|
visualIndex = 0,
|
||||||
}: {
|
}: {
|
||||||
r: Resource;
|
r: RecommendedResource;
|
||||||
visualIndex?: number;
|
visualIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -83,7 +88,13 @@ export function RecommendedCard({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
|
if (r.downloadPostId && r.downloadAttachmentId) {
|
||||||
|
await postNoBody(
|
||||||
|
`/api/posts/${r.downloadPostId}/attachments/${r.downloadAttachmentId}/download`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { LANG_OPTIONS, languageLabel } from "../../i18nLanguages";
|
|
||||||
import { typeFilterLabel } from "../../resourceTypeLabels";
|
import { typeFilterLabel } from "../../resourceTypeLabels";
|
||||||
|
|
||||||
const TYPE_FILTERS = [
|
const TYPE_FILTERS = [
|
||||||
@@ -14,21 +13,12 @@ const TYPE_FILTERS = [
|
|||||||
"archive",
|
"archive",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const LANG_FILTERS = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
|
|
||||||
|
|
||||||
export type FilterChipsProps = {
|
export type FilterChipsProps = {
|
||||||
type: string;
|
type: string;
|
||||||
language: string;
|
|
||||||
onTypeChange: (next: string) => void;
|
onTypeChange: (next: string) => void;
|
||||||
onLanguageChange: (next: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilterChips({
|
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||||
type,
|
|
||||||
language,
|
|
||||||
onTypeChange,
|
|
||||||
onLanguageChange,
|
|
||||||
}: FilterChipsProps) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-10 -mx-3 border-b border-ark-line bg-ark-bg/90 px-3 py-2 backdrop-blur md:-mx-0 md:rounded-t-xl">
|
<div className="sticky top-0 z-10 -mx-3 border-b border-ark-line bg-ark-bg/90 px-3 py-2 backdrop-blur md:-mx-0 md:rounded-t-xl">
|
||||||
@@ -51,25 +41,6 @@ export function FilterChips({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-1.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
||||||
{LANG_FILTERS.map((code) => {
|
|
||||||
const active = language === code;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={code || "all"}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onLanguageChange(code)}
|
|
||||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs transition ${
|
|
||||||
active
|
|
||||||
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
|
|
||||||
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{languageLabel(t, code)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,8 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
const [sp, setSp] = useSearchParams();
|
const [sp, setSp] = useSearchParams();
|
||||||
|
|
||||||
const type = sp.get("type") || "all";
|
const type = sp.get("type") || "all";
|
||||||
const language = sp.get("language") || "";
|
|
||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(() => ({ scope, type, lang }), [scope, type, lang]);
|
||||||
() => ({ scope, type, language, lang }),
|
|
||||||
[scope, type, language, lang],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||||
usePostStream(params);
|
usePostStream(params);
|
||||||
@@ -68,12 +64,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||||
<FilterChips
|
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
||||||
type={type}
|
|
||||||
language={language}
|
|
||||||
onTypeChange={(v) => updateParam("type", v)}
|
|
||||||
onLanguageChange={(v) => updateParam("language", v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 pb-10 pt-2">
|
<div className="flex flex-col gap-2 pb-10 pt-2">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { useLightbox } from "../overlays/ImageLightbox";
|
import { useLightbox } from "../overlays/ImageLightbox";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
const MAX_VISIBLE = 4;
|
const MAX_VISIBLE = 4;
|
||||||
|
|
||||||
@@ -10,7 +12,9 @@ function imageRatio(att: Attachment) {
|
|||||||
|
|
||||||
export function AlbumBubble({ post }: { post: Post }) {
|
export function AlbumBubble({ post }: { post: Post }) {
|
||||||
const { openLightbox } = useLightbox();
|
const { openLightbox } = useLightbox();
|
||||||
|
const { lang } = useI18n();
|
||||||
const images = post.attachments;
|
const images = post.attachments;
|
||||||
|
const text = postDisplayText(post, lang);
|
||||||
const shouldMerge = images.length > MAX_VISIBLE;
|
const shouldMerge = images.length > MAX_VISIBLE;
|
||||||
|
|
||||||
if (!shouldMerge) {
|
if (!shouldMerge) {
|
||||||
@@ -20,7 +24,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
<button
|
<button
|
||||||
key={att.id}
|
key={att.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openLightbox(images, i)}
|
onClick={() => openLightbox(images, i, text, post.id)}
|
||||||
className="relative block max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
|
className="relative block max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
|
||||||
aria-label={att.filename}
|
aria-label={att.filename}
|
||||||
>
|
>
|
||||||
@@ -33,9 +37,9 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{post.text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(post.text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +58,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
<button
|
<button
|
||||||
key={att.id}
|
key={att.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openLightbox(images, i)}
|
onClick={() => openLightbox(images, i, text, post.id)}
|
||||||
className="relative block h-full w-full overflow-hidden"
|
className="relative block h-full w-full overflow-hidden"
|
||||||
aria-label={att.filename}
|
aria-label={att.filename}
|
||||||
>
|
>
|
||||||
@@ -75,9 +79,9 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{post.text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(post.text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
|
import { postNoBody } from "../../../api";
|
||||||
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { fileIcon } from "../utils/fileIcon";
|
import { fileIcon } from "../utils/fileIcon";
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
function AttachmentRow({ att }: { att: Attachment }) {
|
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
||||||
const isImageAsDoc = att.mime.startsWith("image/");
|
const isImageAsDoc = att.mime.startsWith("image/");
|
||||||
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ function AttachmentRow({ att }: { att: Attachment }) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
|
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
|
||||||
|
onClick={() => {
|
||||||
|
void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
|
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
|
||||||
{isImageAsDoc && att.thumbnailUrl ? (
|
{isImageAsDoc && att.thumbnailUrl ? (
|
||||||
@@ -49,14 +55,16 @@ function AttachmentRow({ att }: { att: Attachment }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileDocBubble({ post }: { post: Post }) {
|
export function FileDocBubble({ post }: { post: Post }) {
|
||||||
|
const { lang } = useI18n();
|
||||||
|
const text = postDisplayText(post, lang);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{post.attachments.map((att) => (
|
{post.attachments.map((att) => (
|
||||||
<AttachmentRow key={att.id} att={att} />
|
<AttachmentRow key={att.id} postId={post.id} att={att} />
|
||||||
))}
|
))}
|
||||||
{post.text ? (
|
{text ? (
|
||||||
<div className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{post.text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function ImageBubble({ post }: { post: Post }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openLightbox([att], 0)}
|
onClick={() => openLightbox([att], 0, undefined, post.id)}
|
||||||
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
||||||
aria-label={att.filename}
|
aria-label={att.filename}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
import { useLightbox } from "../overlays/ImageLightbox";
|
import { useLightbox } from "../overlays/ImageLightbox";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
export function ImageWithTextBubble({ post }: { post: Post }) {
|
export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||||
const { openLightbox } = useLightbox();
|
const { openLightbox } = useLightbox();
|
||||||
|
const { lang } = useI18n();
|
||||||
const att = post.attachments[0];
|
const att = post.attachments[0];
|
||||||
|
const text = postDisplayText(post, lang);
|
||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
const ratio =
|
const ratio =
|
||||||
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
||||||
@@ -13,7 +17,7 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openLightbox([att], 0)}
|
onClick={() => openLightbox([att], 0, text, post.id)}
|
||||||
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
||||||
aria-label={att.filename}
|
aria-label={att.filename}
|
||||||
>
|
>
|
||||||
@@ -25,9 +29,9 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
|||||||
style={{ aspectRatio: ratio }}
|
style={{ aspectRatio: ratio }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{post.text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(post.text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
|
import { useI18n } from "../../../i18n";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
export function TextBubble({ post }: { post: Post }) {
|
export function TextBubble({ post }: { post: Post }) {
|
||||||
|
const { lang } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(post.text ?? "")}
|
{autolink(postDisplayText(post, lang))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
|
||||||
function formatDuration(sec: number | undefined): string {
|
function formatDuration(sec: number | undefined): string {
|
||||||
if (!sec || sec <= 0) return "";
|
if (!sec || sec <= 0) return "";
|
||||||
@@ -14,9 +16,11 @@ function formatDuration(sec: number | undefined): string {
|
|||||||
|
|
||||||
export function VideoBubble({ post }: { post: Post }) {
|
export function VideoBubble({ post }: { post: Post }) {
|
||||||
const { openVideo } = useVideoPlayer();
|
const { openVideo } = useVideoPlayer();
|
||||||
|
const { lang } = useI18n();
|
||||||
const att = post.attachments[0];
|
const att = post.attachments[0];
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const text = postDisplayText(post, lang);
|
||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
const ratio =
|
const ratio =
|
||||||
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
||||||
@@ -71,9 +75,9 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{post.text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(post.text)}
|
{autolink(text)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getJSON } from "../../../api";
|
import { getJSON } from "../../../api";
|
||||||
import { langQuery, type Lang } from "../../../i18n";
|
import { langQuery, type Lang } from "../../../i18n";
|
||||||
|
import { sourceLanguageQuery } from "../../../i18nLanguages";
|
||||||
import { MOCK_POSTS } from "../../../mocks/mockPosts";
|
import { MOCK_POSTS } from "../../../mocks/mockPosts";
|
||||||
import type { Post, PostListResponse, PostScope } from "../../../types/post";
|
import type { Post, PostListResponse, PostScope } from "../../../types/post";
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ function buildRealUrl(params: PostStreamParams, cursor?: string): string {
|
|||||||
sp.set("limit", String(PAGE_SIZE));
|
sp.set("limit", String(PAGE_SIZE));
|
||||||
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
|
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
|
||||||
if (params.type && params.type !== "all") sp.set("type", params.type);
|
if (params.type && params.type !== "all") sp.set("type", params.type);
|
||||||
if (params.language) sp.set("language", params.language);
|
if (params.language) sp.set("language", sourceLanguageQuery(params.language));
|
||||||
if (cursor) sp.set("cursor", cursor);
|
if (cursor) sp.set("cursor", cursor);
|
||||||
return `/api/posts?${sp.toString()}`;
|
return `/api/posts?${sp.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,24 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||||
|
import { postNoBody } from "../../../api";
|
||||||
import type { Attachment } from "../../../types/post";
|
import type { Attachment } from "../../../types/post";
|
||||||
|
import { autolink } from "../utils/autolink";
|
||||||
|
|
||||||
type LightboxState = {
|
type LightboxState = {
|
||||||
images: Attachment[];
|
images: Attachment[];
|
||||||
index: number;
|
index: number;
|
||||||
|
caption?: string;
|
||||||
|
postId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type Ctx = {
|
type Ctx = {
|
||||||
openLightbox: (images: Attachment[], startIndex?: number) => void;
|
openLightbox: (
|
||||||
|
images: Attachment[],
|
||||||
|
startIndex?: number,
|
||||||
|
caption?: string,
|
||||||
|
postId?: string,
|
||||||
|
) => void;
|
||||||
closeLightbox: () => void;
|
closeLightbox: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,11 +42,19 @@ export function useLightbox(): Ctx {
|
|||||||
export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||||
const [state, setState] = useState<LightboxState>(null);
|
const [state, setState] = useState<LightboxState>(null);
|
||||||
|
|
||||||
const openLightbox = useCallback((images: Attachment[], startIndex = 0) => {
|
const openLightbox = useCallback(
|
||||||
|
(
|
||||||
|
images: Attachment[],
|
||||||
|
startIndex = 0,
|
||||||
|
caption?: string,
|
||||||
|
postId?: string,
|
||||||
|
) => {
|
||||||
if (!images.length) return;
|
if (!images.length) return;
|
||||||
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
||||||
setState({ images, index: i });
|
setState({ images, index: i, caption, postId });
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const closeLightbox = useCallback(() => setState(null), []);
|
const closeLightbox = useCallback(() => setState(null), []);
|
||||||
|
|
||||||
@@ -48,6 +65,8 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
|||||||
<LightboxView
|
<LightboxView
|
||||||
images={state.images}
|
images={state.images}
|
||||||
startIndex={state.index}
|
startIndex={state.index}
|
||||||
|
caption={state.caption}
|
||||||
|
postId={state.postId}
|
||||||
onClose={closeLightbox}
|
onClose={closeLightbox}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -58,10 +77,14 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
|||||||
function LightboxView({
|
function LightboxView({
|
||||||
images,
|
images,
|
||||||
startIndex,
|
startIndex,
|
||||||
|
caption: captionText,
|
||||||
|
postId,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
images: Attachment[];
|
images: Attachment[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
|
caption?: string;
|
||||||
|
postId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [index, setIndex] = useState(startIndex);
|
const [index, setIndex] = useState(startIndex);
|
||||||
@@ -92,6 +115,7 @@ function LightboxView({
|
|||||||
}, [goPrev, goNext, onClose]);
|
}, [goPrev, goNext, onClose]);
|
||||||
|
|
||||||
const current = images[index];
|
const current = images[index];
|
||||||
|
const caption = captionText?.trim();
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -118,7 +142,14 @@ function LightboxView({
|
|||||||
download={current.filename}
|
download={current.filename}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (postId) {
|
||||||
|
void postNoBody(
|
||||||
|
`/api/posts/${postId}/attachments/${current.id}/download`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
>
|
>
|
||||||
@@ -155,11 +186,8 @@ function LightboxView({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<img
|
<div
|
||||||
src={current.url}
|
className="relative inline-block max-h-[92vh] max-w-[92vw]"
|
||||||
alt={current.filename}
|
|
||||||
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
|
||||||
draggable={false}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
touchStartX.current = e.touches[0].clientX;
|
touchStartX.current = e.touches[0].clientX;
|
||||||
@@ -173,7 +201,21 @@ function LightboxView({
|
|||||||
}
|
}
|
||||||
touchStartX.current = null;
|
touchStartX.current = null;
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={current.url}
|
||||||
|
alt={current.filename}
|
||||||
|
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
{caption ? (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pb-4 pt-12 text-sm leading-snug text-white sm:px-5 sm:pb-5">
|
||||||
|
<div className="max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words">
|
||||||
|
{autolink(caption)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
13
src/components/messageStream/utils/postText.ts
Normal file
13
src/components/messageStream/utils/postText.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { localizationKey } from "../../../i18nLanguages";
|
||||||
|
import type { Post } from "../../../types/post";
|
||||||
|
|
||||||
|
export function postDisplayText(post: Post, lang: string): string {
|
||||||
|
const key = localizationKey(lang);
|
||||||
|
return (
|
||||||
|
post.localizations?.[
|
||||||
|
key as keyof typeof post.localizations
|
||||||
|
]?.text?.trim() ||
|
||||||
|
post.text?.trim() ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,3 +16,11 @@ export function languageLabel(t: (key: string) => string, code: string) {
|
|||||||
const label = t(key);
|
const label = t(key);
|
||||||
return label === key ? code : label;
|
return label === key ? code : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sourceLanguageQuery(code: string) {
|
||||||
|
return code === "zh-CN" ? "zh" : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizationKey(code: string) {
|
||||||
|
return code === "zh-CN" || code.startsWith("zh") ? "zh" : code;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api";
|
import { getJSON, itemsOrEmpty, type Category } from "../api";
|
||||||
import { CategoryIcon } from "../components/CategoryIcon";
|
import { CategoryIcon } from "../components/CategoryIcon";
|
||||||
import { FigmaBanner } from "../components/FigmaBanner";
|
import { FigmaBanner } from "../components/FigmaBanner";
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +12,17 @@ import { RecommendedCard } from "../components/RecommendedCard";
|
|||||||
import { SectionHeader } from "../components/SectionHeader";
|
import { SectionHeader } from "../components/SectionHeader";
|
||||||
import { langQuery, useI18n } from "../i18n";
|
import { langQuery, useI18n } from "../i18n";
|
||||||
import { categoryCardLines } from "../utils/categoryDisplay";
|
import { categoryCardLines } from "../utils/categoryDisplay";
|
||||||
|
import {
|
||||||
|
postToResource,
|
||||||
|
type PostBackedResource,
|
||||||
|
} from "../utils/postResourceAdapter";
|
||||||
|
import type { Post } from "../types/post";
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [rec, setRec] = useState<Resource[]>([]);
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
||||||
const [latest, setLatest] = useState<Resource[]>([]);
|
const [latest, setLatest] = useState<PostBackedResource[]>([]);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const recRowRef = useRef<HTMLDivElement>(null);
|
const recRowRef = useRef<HTMLDivElement>(null);
|
||||||
const [canScrollRec, setCanScrollRec] = useState(false);
|
const [canScrollRec, setCanScrollRec] = useState(false);
|
||||||
@@ -26,18 +31,26 @@ export function Home() {
|
|||||||
const q = `?lang=${encodeURIComponent(langQuery(lang))}`;
|
const q = `?lang=${encodeURIComponent(langQuery(lang))}`;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getJSON<Category[]>(`/api/categories${q}`),
|
getJSON<Category[]>(`/api/categories${q}`),
|
||||||
getJSON<{ items: Resource[] }>(`/api/resources/recommended${q}&limit=12`),
|
getJSON<{ items: Post[] }>(`/api/posts/recommended${q}&limit=12`),
|
||||||
getJSON<{ items: Resource[] }>(`/api/resources/latest${q}&limit=8`),
|
getJSON<{ items: Post[] }>(`/api/posts/latest${q}&limit=8`),
|
||||||
])
|
])
|
||||||
.then(([c, r, l]) => {
|
.then(([c, r, l]) => {
|
||||||
setCats(itemsOrEmpty(c));
|
setCats(itemsOrEmpty(c));
|
||||||
setRec(itemsOrEmpty(r.items));
|
setRec(
|
||||||
setLatest(itemsOrEmpty(l.items));
|
itemsOrEmpty(r.items).map((post) =>
|
||||||
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setLatest(
|
||||||
|
itemsOrEmpty(l.items).map((post) =>
|
||||||
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
|
),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => setErr(String(e)));
|
.catch((e) => setErr(String(e)));
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
const iconKeyForResource = (r: Resource) =>
|
const iconKeyForResource = (r: PostBackedResource) =>
|
||||||
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Navigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { getJSON } from "../api";
|
||||||
|
import { langQuery, useI18n } from "../i18n";
|
||||||
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";
|
||||||
|
|
||||||
export function PostRedirect() {
|
export function PostRedirect() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
// Real-API branch placeholder: when backend ships /api/posts/:id, fetch and
|
const { lang } = useI18n();
|
||||||
// navigate to /category/<categorySlug>#post-<id>. For now mock lookup.
|
const navigate = useNavigate();
|
||||||
const post = id ? MOCK_POSTS.find((p) => p.id === id) : undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (post) {
|
if (!id) {
|
||||||
requestAnimationFrame(() => {
|
navigate("/browse", { replace: true });
|
||||||
document
|
return;
|
||||||
.getElementById(`post-${post.id}`)
|
|
||||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [post]);
|
|
||||||
|
|
||||||
if (!POST_STREAM_USES_MOCK && !post) {
|
if (POST_STREAM_USES_MOCK) {
|
||||||
// TODO: replace with real fetch when /api/posts/:id ships.
|
const post = MOCK_POSTS.find((p) => p.id === id);
|
||||||
return <Navigate to="/browse" replace />;
|
navigate(
|
||||||
}
|
post ? `/category/${post.categorySlug}#post-${post.id}` : "/browse",
|
||||||
if (!post) return <Navigate to="/browse" replace />;
|
{ replace: true },
|
||||||
return (
|
|
||||||
<Navigate to={`/category/${post.categorySlug}#post-${post.id}`} replace />
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getJSON<Post>(
|
||||||
|
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
|
)
|
||||||
|
.then((post) => {
|
||||||
|
navigate(`/category/${post.categorySlug}#post-${post.id}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => navigate("/browse", { replace: true }));
|
||||||
|
}, [id, lang, navigate]);
|
||||||
|
|
||||||
|
return <div className="text-neutral-400">…</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import { getJSON, itemsOrEmpty, postJSON } from "../api";
|
||||||
assetUrl,
|
|
||||||
getJSON,
|
|
||||||
itemsOrEmpty,
|
|
||||||
postJSON,
|
|
||||||
type Resource,
|
|
||||||
} from "../api";
|
|
||||||
import { langQuery, useI18n } from "../i18n";
|
import { langQuery, useI18n } from "../i18n";
|
||||||
import { LANG_OPTIONS, languageLabel } from "../i18nLanguages";
|
import {
|
||||||
|
LANG_OPTIONS,
|
||||||
|
languageLabel,
|
||||||
|
sourceLanguageQuery,
|
||||||
|
} from "../i18nLanguages";
|
||||||
|
import { MessageBubble } from "../components/messageStream/MessageBubble";
|
||||||
import { typeFilterLabel } from "../resourceTypeLabels";
|
import { typeFilterLabel } from "../resourceTypeLabels";
|
||||||
|
import type { Post } from "../types/post";
|
||||||
|
|
||||||
const types = [
|
const types = [
|
||||||
"all",
|
"all",
|
||||||
@@ -24,50 +24,6 @@ const types = [
|
|||||||
] as const;
|
] as const;
|
||||||
const resourceLangCodes = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
|
const resourceLangCodes = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
|
||||||
|
|
||||||
function ResultRow({ r }: { r: Resource }) {
|
|
||||||
const target = r.externalUrl || (r.fileUrl ? assetUrl(r.fileUrl) : null);
|
|
||||||
const inner = (
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-ark-line bg-ark-panel p-3 transition hover:border-ark-gold/55">
|
|
||||||
{r.coverImage ? (
|
|
||||||
<img
|
|
||||||
src={assetUrl(r.coverImage)}
|
|
||||||
alt=""
|
|
||||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-14 w-14 shrink-0 rounded-lg bg-ark-bg" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-semibold text-ark-gold2">
|
|
||||||
{r.title}
|
|
||||||
</div>
|
|
||||||
{r.description ? (
|
|
||||||
<div className="mt-0.5 line-clamp-2 text-xs text-neutral-400">
|
|
||||||
{r.description}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (target) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={target}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Link to={`/category/${r.categorySlug}`} className="block">
|
|
||||||
{inner}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [sp, setSp] = useSearchParams();
|
const [sp, setSp] = useSearchParams();
|
||||||
@@ -75,27 +31,27 @@ export function SearchPage() {
|
|||||||
const type = sp.get("type") || "all";
|
const type = sp.get("type") || "all";
|
||||||
const resourceLang = sp.get("language") || "";
|
const resourceLang = sp.get("language") || "";
|
||||||
|
|
||||||
const [items, setItems] = useState<Resource[]>([]);
|
const [items, setItems] = useState<Post[]>([]);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const query = useMemo(() => {
|
const query = useMemo(() => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set("lang", langQuery(lang));
|
p.set("lang", langQuery(lang));
|
||||||
p.set("limit", "50");
|
p.set("limit", "50");
|
||||||
if (q) p.set("q", q);
|
p.set("q", q);
|
||||||
if (type && type !== "all") p.set("type", type);
|
if (type && type !== "all") p.set("type", type);
|
||||||
if (resourceLang) p.set("language", resourceLang);
|
if (resourceLang) p.set("language", sourceLanguageQuery(resourceLang));
|
||||||
return p.toString();
|
return p.toString();
|
||||||
}, [lang, q, type, resourceLang]);
|
}, [lang, q, type, resourceLang]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
if (!q) {
|
if (!q.trim()) {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
postJSON("/api/search-log", { query: q }).catch(() => {});
|
postJSON("/api/search-log", { query: q }).catch(() => {});
|
||||||
getJSON<{ items: Resource[] }>(`/api/resources?${query}`)
|
getJSON<{ items: Post[] }>(`/api/posts/search?${query}`)
|
||||||
.then((r) => setItems(itemsOrEmpty(r.items)))
|
.then((r) => setItems(itemsOrEmpty(r.items)))
|
||||||
.catch((e) => setErr(String(e)));
|
.catch((e) => setErr(String(e)));
|
||||||
}, [query, q]);
|
}, [query, q]);
|
||||||
@@ -161,8 +117,8 @@ export function SearchPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{items.map((r) => (
|
{items.map((post) => (
|
||||||
<ResultRow key={r.id} r={r} />
|
<MessageBubble key={post.id} post={post} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
|
export type PostLocaleCode = "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
|
||||||
|
|
||||||
|
export type PostType =
|
||||||
|
| "image"
|
||||||
|
| "video"
|
||||||
|
| "music"
|
||||||
|
| "ppt"
|
||||||
|
| "pdf"
|
||||||
|
| "link"
|
||||||
|
| "text"
|
||||||
|
| "archive";
|
||||||
|
|
||||||
|
export type PostTypeFilter = PostType | "all";
|
||||||
export type AttachmentKind = "image" | "video" | "document";
|
export type AttachmentKind = "image" | "video" | "document";
|
||||||
|
|
||||||
|
export type PostLocaleTexts = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostLocalizations = Record<PostLocaleCode, PostLocaleTexts>;
|
||||||
|
|
||||||
export type Attachment = {
|
export type Attachment = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: AttachmentKind;
|
kind: AttachmentKind;
|
||||||
@@ -16,14 +35,19 @@ export type Attachment = {
|
|||||||
|
|
||||||
export type Post = {
|
export type Post = {
|
||||||
id: string;
|
id: string;
|
||||||
|
postType?: PostType | string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
categorySlug: string;
|
categorySlug: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
sourceLanguage?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
localizations?: Partial<PostLocalizations>;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
isRecommended: boolean;
|
isRecommended: boolean;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
updatedAt: string;
|
updatedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PostListResponse = {
|
export type PostListResponse = {
|
||||||
@@ -31,4 +55,8 @@ export type PostListResponse = {
|
|||||||
nextCursor?: string;
|
nextCursor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PostDownloadResponse = {
|
||||||
|
ok: true;
|
||||||
|
};
|
||||||
|
|
||||||
export type PostScope = { kind: "all" } | { kind: "category"; slug: string };
|
export type PostScope = { kind: "all" } | { kind: "category"; slug: string };
|
||||||
|
|||||||
63
src/utils/postResourceAdapter.ts
Normal file
63
src/utils/postResourceAdapter.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Category, Resource } from "../api";
|
||||||
|
import type { Attachment, Post } from "../types/post";
|
||||||
|
import { postDisplayText } from "../components/messageStream/utils/postText";
|
||||||
|
|
||||||
|
export type PostBackedResource = Resource & {
|
||||||
|
downloadPostId?: string;
|
||||||
|
downloadAttachmentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function inferType(post: Post, att: Attachment | undefined): string {
|
||||||
|
if (post.postType) return post.postType;
|
||||||
|
if (!att) return post.text?.includes("http") ? "link" : "text";
|
||||||
|
if (att.kind === "video") return "video";
|
||||||
|
if (att.kind === "image") return "image";
|
||||||
|
const ext = att.filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (["ppt", "pptx", "key"].includes(ext) || att.mime.includes("presentation"))
|
||||||
|
return "ppt";
|
||||||
|
if (ext === "pdf" || att.mime === "application/pdf") return "pdf";
|
||||||
|
if (att.mime.startsWith("audio/") || ext === "mp3") return "music";
|
||||||
|
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) return "archive";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverFor(att: Attachment | undefined) {
|
||||||
|
if (!att) return "";
|
||||||
|
if (att.kind === "image") return att.thumbnailUrl || att.url;
|
||||||
|
if (att.kind === "video") return att.posterUrl || att.thumbnailUrl || "";
|
||||||
|
if (att.mime.startsWith("image/")) return att.thumbnailUrl || att.url;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postToResource(
|
||||||
|
post: Post,
|
||||||
|
lang: string,
|
||||||
|
categories: Category[] = [],
|
||||||
|
): PostBackedResource {
|
||||||
|
const first = post.attachments[0];
|
||||||
|
const title = postDisplayText(post, lang) || first?.filename || post.id;
|
||||||
|
const category = categories.find((c) => c.id === post.categoryId);
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
title,
|
||||||
|
description: postDisplayText(post, lang),
|
||||||
|
type: inferType(post, first),
|
||||||
|
language: post.language,
|
||||||
|
categoryId: post.categoryId,
|
||||||
|
categorySlug: post.categorySlug,
|
||||||
|
categoryName: category?.name || post.categorySlug,
|
||||||
|
coverImage: coverFor(first),
|
||||||
|
fileUrl: first?.url,
|
||||||
|
previewUrl: first?.posterUrl || first?.thumbnailUrl,
|
||||||
|
externalUrl: undefined,
|
||||||
|
bodyText: postDisplayText(post, lang),
|
||||||
|
badgeLabel: post.isRecommended ? "Recommended" : undefined,
|
||||||
|
isDownloadable: !!first,
|
||||||
|
isRecommended: post.isRecommended,
|
||||||
|
publishedAt: post.publishedAt,
|
||||||
|
updatedAt: post.updatedAt || post.publishedAt,
|
||||||
|
tags: post.tags,
|
||||||
|
downloadPostId: post.id,
|
||||||
|
downloadAttachmentId: first?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user