Files
erika/app/share/client.tsx
T

333 lines
13 KiB
TypeScript

"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FileUp, ImagePlus, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { createShareAction } from "@/app/actions/share";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
MAX_SHARE_DESCRIPTION_LENGTH,
MAX_SHARE_IMAGE_BYTES,
MAX_SHARE_TEXT_BYTES,
} from "@/lib/share/limits";
const IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
const TEXT_FILE_TYPES = [
"application/json",
"application/xml",
"application/yaml",
"application/toml",
"application/javascript",
"application/x-javascript",
];
const TEXT_FILE_ACCEPT = `text/*,${TEXT_FILE_TYPES.join(",")}`;
function isTextFile(file: File) {
return !file.type ||
file.type.startsWith("text/") ||
TEXT_FILE_TYPES.includes(file.type);
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
}
export function ShareUploadClient() {
const router = useRouter();
const textFileRef = useRef<HTMLInputElement>(null);
const imageFileRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [description, setDescription] = useState("");
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const byteCount = useMemo(() => new TextEncoder().encode(text).byteLength, [text]);
useEffect(() => {
if (!imagePreview) return;
return () => URL.revokeObjectURL(imagePreview);
}, [imagePreview]);
function showError(message: string) {
setError(message);
toast.error(message);
}
async function loadTextFile(file: File) {
setError(null);
if (!isTextFile(file)) {
showError("รองรับเฉพาะไฟล์ข้อความเท่านั้น");
return;
}
if (file.size > MAX_SHARE_TEXT_BYTES) {
showError("ไฟล์ต้องมีขนาดไม่เกิน 5 MiB");
return;
}
try {
const content = new TextDecoder("utf-8", { fatal: true }).decode(
await file.arrayBuffer()
);
if (content.includes("\0")) {
showError("ไฟล์ข้อความต้องไม่มีอักขระ NUL");
return;
}
setText(content);
toast.success(`เปิด ${file.name} แล้ว`);
} catch {
showError("ไฟล์ต้องเป็นข้อความ UTF-8 ที่ถูกต้อง");
}
}
function chooseImage(file: File | undefined) {
setError(null);
if (!file) return;
if (!IMAGE_TYPES.split(",").includes(file.type)) {
showError("รูปภาพต้องเป็น PNG, JPEG, WebP หรือ GIF");
return;
}
if (file.size > MAX_SHARE_IMAGE_BYTES) {
showError("รูปภาพต้องมีขนาดไม่เกิน 50 MiB");
return;
}
setImage(file);
setImagePreview(URL.createObjectURL(file));
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!text.trim()) {
showError("วางข้อความก่อนสร้างลิงก์");
return;
}
if (byteCount > MAX_SHARE_TEXT_BYTES) {
showError("ข้อความต้องมีขนาดไม่เกิน 5 MiB");
return;
}
setPending(true);
const formData = new FormData();
formData.set("text", text);
formData.set("description", description);
if (image) formData.set("image", image);
try {
const result = await createShareAction(formData);
if (!result.success || !result.id) {
showError(result.success ? "สร้างลิงก์ไม่สำเร็จ" : result.error);
return;
}
router.push(`/share/${result.id}`);
} catch {
showError("เชื่อมต่อเซิร์ฟเวอร์ไม่สำเร็จ ลองใหม่อีกครั้ง");
} finally {
setPending(false);
}
}
return (
<form onSubmit={submit}>
<Card className="overflow-hidden">
<CardHeader className="border-b">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<CardTitle className="font-mono text-base">share://new</CardTitle>
<CardDescription>
วางข้อความหรือเลือกไฟล์ แล้วรับลิงก์สำหรับส่งต่อ
</CardDescription>
</div>
<div className="font-mono text-xs text-muted-foreground">
{formatBytes(byteCount)} / 5 MiB
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<FieldGroup className="gap-0">
<Field
data-invalid={Boolean(error)}
className={cn(
"relative gap-0 border-b transition-colors",
dragging && "bg-muted/50"
)}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
setDragging(false);
}
}}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files[0];
if (file) void loadTextFile(file);
}}
>
<FieldLabel htmlFor="share-text" className="sr-only">
ข้อความที่ต้องการแชร์
</FieldLabel>
<Textarea
id="share-text"
value={text}
onChange={(event) => setText(event.target.value)}
aria-invalid={Boolean(error)}
placeholder="วางข้อความของคุณที่นี่…"
className="min-h-[52vh] resize-y rounded-none border-0 bg-transparent p-5 font-mono text-sm leading-6 shadow-none focus-visible:ring-0 sm:p-7"
spellCheck={false}
/>
<div className="flex flex-wrap items-center justify-between gap-3 border-t bg-muted/20 px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => textFileRef.current?.click()}
>
<FileUp data-icon="inline-start" />
เลือกไฟล์
</Button>
<span className="text-xs text-muted-foreground">
หรือลากไฟล์มาวางในช่องข้อความ
</span>
</div>
<span className="font-mono text-xs text-muted-foreground">
{text.split("\n").length.toLocaleString("th-TH")} บรรทัด
</span>
</div>
<Input
ref={textFileRef}
type="file"
accept={TEXT_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void loadTextFile(file);
event.target.value = "";
}}
/>
</Field>
<div className="p-5 sm:p-7">
<FieldGroup>
<Field>
<FieldLabel htmlFor="share-description">
คำอธิบาย <span className="font-normal text-muted-foreground">(ไม่บังคับ)</span>
</FieldLabel>
<Textarea
id="share-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={MAX_SHARE_DESCRIPTION_LENGTH}
placeholder="บอกคนรับลิงก์ว่าข้อความนี้เกี่ยวกับอะไร"
className="min-h-24 resize-y"
/>
<FieldDescription className="text-right">
{description.length.toLocaleString("th-TH")} / 5,000
</FieldDescription>
</Field>
<Field>
<FieldLabel>รูปประกอบ (ไม่บังคับ)</FieldLabel>
{imagePreview ? (
<div className="overflow-hidden rounded-lg border">
<div className="relative aspect-video bg-muted/30">
<Image
src={imagePreview}
alt="ตัวอย่างรูปประกอบ"
fill
unoptimized
className="object-contain"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-3 py-2">
<span className="truncate text-xs text-muted-foreground">
{image?.name} · {formatBytes(image?.size ?? 0)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
>
<X data-icon="inline-start" />
ลบรูป
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() => imageFileRef.current?.click()}
>
<ImagePlus data-icon="inline-start" />
เพิ่มรูปภาพ
</Button>
)}
<Input
ref={imageFileRef}
type="file"
accept={IMAGE_TYPES}
className="hidden"
onChange={(event) => {
chooseImage(event.target.files?.[0]);
event.target.value = "";
}}
/>
<FieldDescription>
PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 50 MiB
</FieldDescription>
</Field>
{error && (
<Alert variant="destructive">
<AlertTitle>ยังสร้างลิงก์ไม่ได้</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<FieldError className="sr-only">{error}</FieldError>
<Button type="submit" size="lg" disabled={pending || !text.trim()}>
{pending ? <Spinner data-icon="inline-start" /> : <Upload data-icon="inline-start" />}
อัปโหลดและสร้างลิงก์
</Button>
</FieldGroup>
</div>
</FieldGroup>
</CardContent>
</Card>
</form>
);
}