feat: implement direct file upload functionality with S3 integration

This commit is contained in:
암냥 2026-04-23 19:46:43 +09:00
commit b9a522c6d7
No known key found for this signature in database
3 changed files with 220 additions and 39 deletions

View file

@ -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>