feat: implement direct file upload functionality with S3 integration
This commit is contained in:
parent
3519e1307b
commit
b9a522c6d7
3 changed files with 220 additions and 39 deletions
|
|
@ -1,15 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Header from "../../components/header";
|
||||
|
||||
type SourceType = "twitter" | "pixiv";
|
||||
type SourceType = "twitter" | "pixiv" | "direct";
|
||||
|
||||
type PreviewItem = {
|
||||
url: string;
|
||||
type?: "image" | "video";
|
||||
file?: File;
|
||||
};
|
||||
|
||||
type TweetApiResponse = {
|
||||
|
|
@ -88,6 +89,8 @@ export default function AddPage() {
|
|||
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
|
||||
const [loadingRole, setLoadingRole] = useState(true);
|
||||
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
|
||||
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => selected.filter(Boolean).length,
|
||||
|
|
@ -133,6 +136,9 @@ export default function AddPage() {
|
|||
}, []);
|
||||
|
||||
function resetPreview() {
|
||||
previewItems.forEach((item) => {
|
||||
if (item.file) URL.revokeObjectURL(item.url);
|
||||
});
|
||||
setPreviewItems([]);
|
||||
setSelected([]);
|
||||
setSourceType(null);
|
||||
|
|
@ -140,6 +146,7 @@ export default function AddPage() {
|
|||
setError(null);
|
||||
setSuccess(null);
|
||||
setExistingDetailId(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
async function fetchPreview(targetUrl?: string) {
|
||||
|
|
@ -243,6 +250,21 @@ export default function AddPage() {
|
|||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setSourceType("direct");
|
||||
const newItems: PreviewItem[] = files.map((file) => ({
|
||||
url: URL.createObjectURL(file),
|
||||
type: file.type.startsWith("video/") ? "video" : "image",
|
||||
file,
|
||||
}));
|
||||
|
||||
setPreviewItems((prev) => [...prev, ...newItems]);
|
||||
setSelected((prev) => [...prev, ...newItems.map(() => true)]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingPreview) {
|
||||
return;
|
||||
|
|
@ -292,18 +314,36 @@ export default function AddPage() {
|
|||
setSubmitting(true);
|
||||
try {
|
||||
const tags = splitTags(tagsText);
|
||||
const response = await fetch("/api/post/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
author: author.trim() || undefined,
|
||||
tag: tags.length > 0 ? tags : undefined,
|
||||
selected,
|
||||
}),
|
||||
});
|
||||
let response: Response;
|
||||
|
||||
if (uploadMode === "direct") {
|
||||
const formData = new FormData();
|
||||
previewItems.forEach((item, index) => {
|
||||
if (selected[index] && item.file) {
|
||||
formData.append("files", item.file);
|
||||
}
|
||||
});
|
||||
if (author.trim()) formData.append("author", author.trim());
|
||||
tags.forEach((t) => formData.append("tag", t));
|
||||
|
||||
response = await fetch("/api/post/upload/direct", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
} else {
|
||||
response = await fetch("/api/post/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
author: author.trim() || undefined,
|
||||
tag: tags.length > 0 ? tags : undefined,
|
||||
selected,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let data: UploadApiResponse | null = null;
|
||||
try {
|
||||
|
|
@ -359,36 +399,85 @@ export default function AddPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={submit}>
|
||||
<form className="mt-5 space-y-6" onSubmit={submit}>
|
||||
{!loadingRole && !canManagePost ? (
|
||||
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
|
||||
if (previewItems.length > 0 || sourceType) {
|
||||
resetPreview();
|
||||
}
|
||||
}}
|
||||
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
|
||||
disabled={loadingPreview}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/55">URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.</p>
|
||||
<div className="flex gap-6 border-b border-border/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMode("url");
|
||||
resetPreview();
|
||||
}}
|
||||
className={`pb-2 text-sm font-medium transition ${uploadMode === "url" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||
>
|
||||
URL 가져오기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMode("direct");
|
||||
resetPreview();
|
||||
}}
|
||||
className={`pb-2 text-sm font-medium transition ${uploadMode === "direct" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||
>
|
||||
직접 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uploadMode === "url" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
|
||||
if (previewItems.length > 0 || sourceType) {
|
||||
resetPreview();
|
||||
}
|
||||
}}
|
||||
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
|
||||
disabled={loadingPreview}
|
||||
required={uploadMode === "url"}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/55">URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80">파일 선택</label>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-3 border-2 border-dashed border-border/60 bg-white/5 py-12 transition hover:border-foreground/30 hover:bg-white/10 cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<svg className="h-10 w-10 text-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">클릭하여 이미지나 영상을 선택하세요</p>
|
||||
<p className="mt-1 text-xs text-foreground/40">여러 파일을 동시에 선택할 수 있습니다.</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue