This commit is contained in:
암냥 2026-04-23 19:11:16 +09:00
commit 3c601295b1
No known key found for this signature in database
5 changed files with 82 additions and 13 deletions

View file

@ -9,12 +9,20 @@ type SourceType = "twitter" | "pixiv";
type PreviewItem = {
url: string;
type?: "image" | "video";
};
type TweetApiResponse = {
tweet?: {
author?: { name?: string };
media?: { photos?: Array<{ url?: string }> };
media?: {
photos?: Array<{ url?: string }>;
all?: Array<{
url?: string;
thumbnail_url?: string;
type?: "photo" | "video" | "gif";
}>;
};
};
};
@ -176,10 +184,13 @@ export default function AddPage() {
}
const data = (await response.json()) as TweetApiResponse;
const items = (data.tweet?.media?.photos ?? [])
.map((photo) => photo.url)
.filter((photoUrl): photoUrl is string => typeof photoUrl === "string" && photoUrl.length > 0)
.map((photoUrl) => ({ url: photoUrl }));
const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || [];
const items: PreviewItem[] = mediaItems
.map((m) => ({
url: (m as any).thumbnail_url || (m as any).url || "",
type: ((m as any).type === "video" || (m as any).type === "gif") ? "video" : "image"
}))
.filter((item) => item.url.length > 0);
if (items.length === 0) {
throw new Error("이미지를 찾지 못했습니다.");
@ -462,6 +473,13 @@ export default function AddPage() {
decoding="async"
className={`aspect-4/5 w-full object-cover transition ${selected[index] ? "opacity-100" : "opacity-35 grayscale-20"}`}
/>
{item.type === "video" && (
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
<svg className="h-10 w-10 text-white/80 drop-shadow-md" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
</div>
)}
<div className="absolute left-2 top-2 border border-border/60 bg-background/80 px-2 py-1 text-xs backdrop-blur">
<input
type="checkbox"

View file

@ -19,6 +19,8 @@ type PostDetailResponse = {
};
mediaUrl?: string;
mediaIndex?: number;
mediaType?: "image" | "video";
thumbnailUrl?: string;
};
type Me = {
@ -243,7 +245,17 @@ export default async function DetailPage({ params }: { params: Promise<{ id: str
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<div className="space-y-3">
{post.mediaUrl ? (
{post.mediaType === "video" ? (
<video
src={post.mediaUrl}
poster={post.thumbnailUrl}
controls
loop
muted
autoPlay
className="w-full border border-border bg-black object-contain"
/>
) : post.mediaUrl ? (
<img
src={post.mediaUrl}
alt={post.author ?? post._id}

View file

@ -10,6 +10,8 @@ type Upload = {
mediaIndex: number;
mediaUrl: string;
s3Key: string;
mediaType?: "image" | "video";
thumbnailUrl?: string;
author?: string;
tweet: {
url: string;
@ -30,6 +32,8 @@ type GalleryPhoto = {
alt: string;
author: string;
source: string;
mediaType?: "image" | "video";
thumbnailUrl?: string;
};
type Me = {
@ -435,7 +439,8 @@ export default function App() {
(upload) =>
new Promise<GalleryPhoto | null>((resolve) => {
const image = new Image();
image.src = upload.mediaUrl;
const srcToLoad = (upload.mediaType === "video" && upload.thumbnailUrl) ? upload.thumbnailUrl : upload.mediaUrl;
image.src = srcToLoad;
image.onload = () => {
resolve({
src: upload.mediaUrl,
@ -445,6 +450,8 @@ export default function App() {
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
source: upload.tweet?.url || "",
mediaType: upload.mediaType,
thumbnailUrl: upload.thumbnailUrl,
});
};
image.onerror = () => {
@ -456,6 +463,8 @@ export default function App() {
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
source: upload.tweet?.url || "",
mediaType: upload.mediaType,
thumbnailUrl: upload.thumbnailUrl,
});
};
}),
@ -547,6 +556,13 @@ export default function App() {
decoding="async"
className="block w-full"
/>
{photo.mediaType === "video" && (
<div className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white backdrop-blur-sm">
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
</div>
)}
<div className="absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<button
type="button"
@ -603,6 +619,22 @@ export default function App() {
<div className="my-1 h-px bg-border" />
</>
) : null}
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(contextMenu.photo.source).then(() => {
setContextMenu(null);
}).catch((err) => {
console.error("Failed to copy link:", err);
alert("링크 복사에 실패했습니다.");
});
}}
>
</button>
<div className="my-1 h-px bg-border" />
<button
type="button"
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"