333 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|