wow
This commit is contained in:
parent
d5c9469624
commit
3c601295b1
5 changed files with 82 additions and 13 deletions
|
|
@ -11,6 +11,8 @@ const mediaUploadSchema = new mongoose.Schema({
|
||||||
mediaIndex: { type: Number, required: true },
|
mediaIndex: { type: Number, required: true },
|
||||||
mediaUrl: { type: String, required: true },
|
mediaUrl: { type: String, required: true },
|
||||||
s3Key: { type: String, required: true },
|
s3Key: { type: String, required: true },
|
||||||
|
mediaType: { type: String, enum: ["image", "video"], default: "image" },
|
||||||
|
thumbnailUrl: { type: String },
|
||||||
tags: { type: [String], default: [] },
|
tags: { type: [String], default: [] },
|
||||||
author: { type: String, required: true },
|
author: { type: String, required: true },
|
||||||
uploadedBy: {
|
uploadedBy: {
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,8 @@ export default new Elysia({ prefix: "/post" })
|
||||||
} : undefined,
|
} : undefined,
|
||||||
mediaUrl: post.mediaUrl,
|
mediaUrl: post.mediaUrl,
|
||||||
mediaIndex: post.mediaIndex,
|
mediaIndex: post.mediaIndex,
|
||||||
|
mediaType: post.mediaType,
|
||||||
|
thumbnailUrl: post.thumbnailUrl,
|
||||||
};
|
};
|
||||||
}, {
|
}, {
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
|
|
@ -558,12 +560,11 @@ export default new Elysia({ prefix: "/post" })
|
||||||
let savedCount = 0;
|
let savedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
if (tweetData.tweet) {
|
if (tweetData.tweet) {
|
||||||
const media = tweetData.tweet.media.photos || [];
|
const media = tweetData.tweet.media.all || tweetData.tweet.media.photos || [];
|
||||||
if (media.length > 0) {
|
if (media.length > 0) {
|
||||||
const mediaUrls = media.map((m: any) => m.url);
|
|
||||||
const hasExplicitSelection = body.selected.length > 0;
|
const hasExplicitSelection = body.selected.length > 0;
|
||||||
const savedIds: string[] = [];
|
const savedIds: string[] = [];
|
||||||
for (const [index, url] of mediaUrls.entries()) {
|
for (const [index, mediaItem] of media.entries()) {
|
||||||
const isSelected = hasExplicitSelection
|
const isSelected = hasExplicitSelection
|
||||||
? body.selected[index] === true
|
? body.selected[index] === true
|
||||||
: true;
|
: true;
|
||||||
|
|
@ -572,13 +573,15 @@ export default new Elysia({ prefix: "/post" })
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, url, index);
|
const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, mediaItem.url, index);
|
||||||
|
|
||||||
const { media: _media, ...tweetWithoutMedia } = tweetData.tweet;
|
const { media: _media, ...tweetWithoutMedia } = tweetData.tweet;
|
||||||
const normalizedTags = normalizeTags(body.tag || ["미분류"]);
|
const normalizedTags = normalizeTags(body.tag || ["미분류"]);
|
||||||
|
const mediaType = (mediaItem.type === "video" || mediaItem.type === "gif") ? "video" : "image";
|
||||||
|
|
||||||
const result = await uploadAndCreateWithRetry({
|
const result = await uploadAndCreateWithRetry({
|
||||||
fileName,
|
fileName,
|
||||||
mediaUrl: url,
|
mediaUrl: mediaItem.url,
|
||||||
mediaIndex: index,
|
mediaIndex: index,
|
||||||
createDocument: async () => {
|
createDocument: async () => {
|
||||||
return await MediaUpload.create({
|
return await MediaUpload.create({
|
||||||
|
|
@ -587,6 +590,8 @@ export default new Elysia({ prefix: "/post" })
|
||||||
mediaIndex: index,
|
mediaIndex: index,
|
||||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||||
s3Key: fileName,
|
s3Key: fileName,
|
||||||
|
mediaType,
|
||||||
|
thumbnailUrl: mediaItem.thumbnail_url,
|
||||||
tags: normalizedTags,
|
tags: normalizedTags,
|
||||||
author: body.author ? body.author : tweetData.tweet.author.name,
|
author: body.author ? body.author : tweetData.tweet.author.name,
|
||||||
uploadedBy: {
|
uploadedBy: {
|
||||||
|
|
@ -601,7 +606,7 @@ export default new Elysia({ prefix: "/post" })
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
console.error(`[Upload failed] index=${index} url=${url} key=${fileName}`, result.error);
|
console.error(`[Upload failed] index=${index} url=${mediaItem.url} key=${fileName}`, result.error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,20 @@ type SourceType = "twitter" | "pixiv";
|
||||||
|
|
||||||
type PreviewItem = {
|
type PreviewItem = {
|
||||||
url: string;
|
url: string;
|
||||||
|
type?: "image" | "video";
|
||||||
};
|
};
|
||||||
|
|
||||||
type TweetApiResponse = {
|
type TweetApiResponse = {
|
||||||
tweet?: {
|
tweet?: {
|
||||||
author?: { name?: string };
|
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 data = (await response.json()) as TweetApiResponse;
|
||||||
const items = (data.tweet?.media?.photos ?? [])
|
const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || [];
|
||||||
.map((photo) => photo.url)
|
const items: PreviewItem[] = mediaItems
|
||||||
.filter((photoUrl): photoUrl is string => typeof photoUrl === "string" && photoUrl.length > 0)
|
.map((m) => ({
|
||||||
.map((photoUrl) => ({ url: photoUrl }));
|
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) {
|
if (items.length === 0) {
|
||||||
throw new Error("이미지를 찾지 못했습니다.");
|
throw new Error("이미지를 찾지 못했습니다.");
|
||||||
|
|
@ -462,6 +473,13 @@ export default function AddPage() {
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className={`aspect-4/5 w-full object-cover transition ${selected[index] ? "opacity-100" : "opacity-35 grayscale-20"}`}
|
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">
|
<div className="absolute left-2 top-2 border border-border/60 bg-background/80 px-2 py-1 text-xs backdrop-blur">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ type PostDetailResponse = {
|
||||||
};
|
};
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
mediaIndex?: number;
|
mediaIndex?: number;
|
||||||
|
mediaType?: "image" | "video";
|
||||||
|
thumbnailUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Me = {
|
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="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||||
<div className="space-y-3">
|
<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
|
<img
|
||||||
src={post.mediaUrl}
|
src={post.mediaUrl}
|
||||||
alt={post.author ?? post._id}
|
alt={post.author ?? post._id}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ type Upload = {
|
||||||
mediaIndex: number;
|
mediaIndex: number;
|
||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
s3Key: string;
|
s3Key: string;
|
||||||
|
mediaType?: "image" | "video";
|
||||||
|
thumbnailUrl?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
tweet: {
|
tweet: {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -30,6 +32,8 @@ type GalleryPhoto = {
|
||||||
alt: string;
|
alt: string;
|
||||||
author: string;
|
author: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
mediaType?: "image" | "video";
|
||||||
|
thumbnailUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Me = {
|
type Me = {
|
||||||
|
|
@ -435,7 +439,8 @@ export default function App() {
|
||||||
(upload) =>
|
(upload) =>
|
||||||
new Promise<GalleryPhoto | null>((resolve) => {
|
new Promise<GalleryPhoto | null>((resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = upload.mediaUrl;
|
const srcToLoad = (upload.mediaType === "video" && upload.thumbnailUrl) ? upload.thumbnailUrl : upload.mediaUrl;
|
||||||
|
image.src = srcToLoad;
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
resolve({
|
resolve({
|
||||||
src: upload.mediaUrl,
|
src: upload.mediaUrl,
|
||||||
|
|
@ -445,6 +450,8 @@ export default function App() {
|
||||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||||
author: upload.author?.trim() || "unknown",
|
author: upload.author?.trim() || "unknown",
|
||||||
source: upload.tweet?.url || "",
|
source: upload.tweet?.url || "",
|
||||||
|
mediaType: upload.mediaType,
|
||||||
|
thumbnailUrl: upload.thumbnailUrl,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
image.onerror = () => {
|
image.onerror = () => {
|
||||||
|
|
@ -456,6 +463,8 @@ export default function App() {
|
||||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||||
author: upload.author?.trim() || "unknown",
|
author: upload.author?.trim() || "unknown",
|
||||||
source: upload.tweet?.url || "",
|
source: upload.tweet?.url || "",
|
||||||
|
mediaType: upload.mediaType,
|
||||||
|
thumbnailUrl: upload.thumbnailUrl,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
@ -547,6 +556,13 @@ export default function App() {
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="block w-full"
|
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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -603,6 +619,22 @@ export default function App() {
|
||||||
<div className="my-1 h-px bg-border" />
|
<div className="my-1 h-px bg-border" />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
|
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-accent"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue