feat: wire public posts api
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { assetUrl, postJSON } from "../api";
|
||||
import { assetUrl, postJSON, postNoBody } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useMemo } from "react";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
@@ -14,11 +14,16 @@ function isPlaceholderAsset(path: string | undefined | null) {
|
||||
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]";
|
||||
|
||||
type RecommendedResource = Resource & {
|
||||
downloadPostId?: string;
|
||||
downloadAttachmentId?: string;
|
||||
};
|
||||
|
||||
export function RecommendedCard({
|
||||
r,
|
||||
visualIndex = 0,
|
||||
}: {
|
||||
r: Resource;
|
||||
r: RecommendedResource;
|
||||
visualIndex?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -83,7 +88,13 @@ export function RecommendedCard({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||
if (r.downloadPostId && r.downloadAttachmentId) {
|
||||
await postNoBody(
|
||||
`/api/posts/${r.downloadPostId}/attachments/${r.downloadAttachmentId}/download`,
|
||||
);
|
||||
} else {
|
||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useI18n } from "../../i18n";
|
||||
import { LANG_OPTIONS, languageLabel } from "../../i18nLanguages";
|
||||
import { typeFilterLabel } from "../../resourceTypeLabels";
|
||||
|
||||
const TYPE_FILTERS = [
|
||||
@@ -14,21 +13,12 @@ const TYPE_FILTERS = [
|
||||
"archive",
|
||||
] as const;
|
||||
|
||||
const LANG_FILTERS = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
|
||||
|
||||
export type FilterChipsProps = {
|
||||
type: string;
|
||||
language: string;
|
||||
onTypeChange: (next: string) => void;
|
||||
onLanguageChange: (next: string) => void;
|
||||
};
|
||||
|
||||
export function FilterChips({
|
||||
type,
|
||||
language,
|
||||
onTypeChange,
|
||||
onLanguageChange,
|
||||
}: FilterChipsProps) {
|
||||
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||
const { t } = useI18n();
|
||||
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">
|
||||
@@ -51,25 +41,6 @@ export function FilterChips({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,8 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const [sp, setSp] = useSearchParams();
|
||||
|
||||
const type = sp.get("type") || "all";
|
||||
const language = sp.get("language") || "";
|
||||
|
||||
const params = useMemo(
|
||||
() => ({ scope, type, language, lang }),
|
||||
[scope, type, language, lang],
|
||||
);
|
||||
const params = useMemo(() => ({ scope, type, lang }), [scope, type, lang]);
|
||||
|
||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||
usePostStream(params);
|
||||
@@ -68,12 +64,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||
<FilterChips
|
||||
type={type}
|
||||
language={language}
|
||||
onTypeChange={(v) => updateParam("type", v)}
|
||||
onLanguageChange={(v) => updateParam("language", v)}
|
||||
/>
|
||||
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
||||
|
||||
<div className="flex flex-col gap-2 pb-10 pt-2">
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Attachment, Post } from "../../../types/post";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
@@ -10,7 +12,9 @@ function imageRatio(att: Attachment) {
|
||||
|
||||
export function AlbumBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const { lang } = useI18n();
|
||||
const images = post.attachments;
|
||||
const text = postDisplayText(post, lang);
|
||||
const shouldMerge = images.length > MAX_VISIBLE;
|
||||
|
||||
if (!shouldMerge) {
|
||||
@@ -20,7 +24,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
<button
|
||||
key={att.id}
|
||||
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]"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
@@ -33,9 +37,9 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{post.text ? (
|
||||
{text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
{autolink(text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -54,7 +58,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => openLightbox(images, i)}
|
||||
onClick={() => openLightbox(images, i, text, post.id)}
|
||||
className="relative block h-full w-full overflow-hidden"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
@@ -75,9 +79,9 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{post.text ? (
|
||||
{text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
{autolink(text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { postNoBody } from "../../../api";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Attachment, Post } from "../../../types/post";
|
||||
import { fileIcon } from "../utils/fileIcon";
|
||||
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 { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||
|
||||
@@ -14,6 +17,9 @@ function AttachmentRow({ att }: { att: Attachment }) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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">
|
||||
{isImageAsDoc && att.thumbnailUrl ? (
|
||||
@@ -49,14 +55,16 @@ function AttachmentRow({ att }: { att: Attachment }) {
|
||||
}
|
||||
|
||||
export function FileDocBubble({ post }: { post: Post }) {
|
||||
const { lang } = useI18n();
|
||||
const text = postDisplayText(post, lang);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{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">
|
||||
{post.text}
|
||||
{text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ImageBubble({ post }: { post: Post }) {
|
||||
return (
|
||||
<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]"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
|
||||
export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const { lang } = useI18n();
|
||||
const att = post.attachments[0];
|
||||
const text = postDisplayText(post, lang);
|
||||
if (!att) return null;
|
||||
const ratio =
|
||||
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">
|
||||
<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]"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
@@ -25,9 +29,9 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||
style={{ aspectRatio: ratio }}
|
||||
/>
|
||||
</button>
|
||||
{post.text ? (
|
||||
{text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
{autolink(text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
|
||||
export function TextBubble({ post }: { post: Post }) {
|
||||
const { lang } = useI18n();
|
||||
return (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text ?? "")}
|
||||
{autolink(postDisplayText(post, lang))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Play } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { formatBytes } from "../utils/formatBytes";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
|
||||
function formatDuration(sec: number | undefined): string {
|
||||
if (!sec || sec <= 0) return "";
|
||||
@@ -14,9 +16,11 @@ function formatDuration(sec: number | undefined): string {
|
||||
|
||||
export function VideoBubble({ post }: { post: Post }) {
|
||||
const { openVideo } = useVideoPlayer();
|
||||
const { lang } = useI18n();
|
||||
const att = post.attachments[0];
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const text = postDisplayText(post, lang);
|
||||
if (!att) return null;
|
||||
const ratio =
|
||||
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
||||
@@ -71,9 +75,9 @@ export function VideoBubble({ post }: { post: Post }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{post.text ? (
|
||||
{text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
{autolink(text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getJSON } from "../../../api";
|
||||
import { langQuery, type Lang } from "../../../i18n";
|
||||
import { sourceLanguageQuery } from "../../../i18nLanguages";
|
||||
import { MOCK_POSTS } from "../../../mocks/mockPosts";
|
||||
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));
|
||||
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
|
||||
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);
|
||||
return `/api/posts?${sp.toString()}`;
|
||||
}
|
||||
|
||||
@@ -9,15 +9,24 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
import { postNoBody } from "../../../api";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { autolink } from "../utils/autolink";
|
||||
|
||||
type LightboxState = {
|
||||
images: Attachment[];
|
||||
index: number;
|
||||
caption?: string;
|
||||
postId?: string;
|
||||
} | null;
|
||||
|
||||
type Ctx = {
|
||||
openLightbox: (images: Attachment[], startIndex?: number) => void;
|
||||
openLightbox: (
|
||||
images: Attachment[],
|
||||
startIndex?: number,
|
||||
caption?: string,
|
||||
postId?: string,
|
||||
) => void;
|
||||
closeLightbox: () => void;
|
||||
};
|
||||
|
||||
@@ -33,11 +42,19 @@ export function useLightbox(): Ctx {
|
||||
export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<LightboxState>(null);
|
||||
|
||||
const openLightbox = useCallback((images: Attachment[], startIndex = 0) => {
|
||||
if (!images.length) return;
|
||||
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
||||
setState({ images, index: i });
|
||||
}, []);
|
||||
const openLightbox = useCallback(
|
||||
(
|
||||
images: Attachment[],
|
||||
startIndex = 0,
|
||||
caption?: string,
|
||||
postId?: string,
|
||||
) => {
|
||||
if (!images.length) return;
|
||||
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
||||
setState({ images, index: i, caption, postId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeLightbox = useCallback(() => setState(null), []);
|
||||
|
||||
@@ -48,6 +65,8 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
<LightboxView
|
||||
images={state.images}
|
||||
startIndex={state.index}
|
||||
caption={state.caption}
|
||||
postId={state.postId}
|
||||
onClose={closeLightbox}
|
||||
/>
|
||||
) : null}
|
||||
@@ -58,10 +77,14 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
function LightboxView({
|
||||
images,
|
||||
startIndex,
|
||||
caption: captionText,
|
||||
postId,
|
||||
onClose,
|
||||
}: {
|
||||
images: Attachment[];
|
||||
startIndex: number;
|
||||
caption?: string;
|
||||
postId?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
@@ -92,6 +115,7 @@ function LightboxView({
|
||||
}, [goPrev, goNext, onClose]);
|
||||
|
||||
const current = images[index];
|
||||
const caption = captionText?.trim();
|
||||
if (!current) return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -118,7 +142,14 @@ function LightboxView({
|
||||
download={current.filename}
|
||||
target="_blank"
|
||||
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"
|
||||
aria-label="Download"
|
||||
>
|
||||
@@ -155,11 +186,8 @@ function LightboxView({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.filename}
|
||||
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
||||
draggable={false}
|
||||
<div
|
||||
className="relative inline-block max-h-[92vh] max-w-[92vw]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
@@ -173,7 +201,21 @@ function LightboxView({
|
||||
}
|
||||
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>,
|
||||
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() ||
|
||||
""
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user