178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Upload, Trash2, CheckCircle2, AlertCircle, ExternalLink } from "lucide-react";
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
interface TestResult {
|
|
key: string;
|
|
streamUrl: string;
|
|
filename: string;
|
|
fileSize: number;
|
|
uploadMs: number;
|
|
expiresAt: string;
|
|
}
|
|
|
|
export default function StorageTestPage() {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [result, setResult] = useState<TestResult | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleUpload = async () => {
|
|
if (!file) return;
|
|
setUploading(true);
|
|
setError(null);
|
|
setResult(null);
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
try {
|
|
const res = await fetch("/api/storage-test", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? "Upload failed");
|
|
setResult(data);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Upload failed");
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!result) return;
|
|
setDeleting(true);
|
|
try {
|
|
const res = await fetch(`/api/storage-test?key=${encodeURIComponent(result.key)}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error ?? "Delete failed");
|
|
}
|
|
setResult(null);
|
|
setFile(null);
|
|
if (inputRef.current) inputRef.current.value = "";
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Delete failed");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 max-w-3xl space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Object Storage Playback Test</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Upload a video to Hetzner object storage and play it back directly. Use this to compare
|
|
streaming performance against the local file server.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Upload */}
|
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
|
<div
|
|
className="border-2 border-dashed border-border rounded-md p-8 text-center cursor-pointer hover:border-primary/40 transition-colors"
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
|
{file ? (
|
|
<p className="text-sm font-medium">
|
|
{file.name}{" "}
|
|
<span className="text-muted-foreground">({formatBytes(file.size)})</span>
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Click to select a video file</p>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="video/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
setFile(e.target.files?.[0] ?? null);
|
|
setResult(null);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full">
|
|
{uploading ? "Uploading to Hetzner…" : "Upload & Test Playback"}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Result */}
|
|
{result && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 text-sm text-emerald-400 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-2">
|
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
|
<span>
|
|
Uploaded in <strong>{result.uploadMs}ms</strong> · {formatBytes(result.fileSize)} ·
|
|
URL expires at {new Date(result.expiresAt).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Player */}
|
|
<div className="rounded-lg overflow-hidden bg-black border border-border aspect-video">
|
|
<video
|
|
key={result.streamUrl}
|
|
src={result.streamUrl}
|
|
controls
|
|
className="w-full h-full"
|
|
preload="metadata"
|
|
/>
|
|
</div>
|
|
|
|
{/* URL + cleanup */}
|
|
<div className="flex items-start gap-2">
|
|
<code className="flex-1 text-xs text-muted-foreground bg-muted rounded px-2 py-1.5 break-all select-all">
|
|
{result.streamUrl}
|
|
</code>
|
|
<a
|
|
href={result.streamUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="Open URL directly"
|
|
className="shrink-0 p-1.5 rounded border border-border hover:bg-muted transition-colors"
|
|
>
|
|
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
|
</a>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5 text-red-400 border-red-500/30 hover:bg-red-500/10"
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
{deleting ? "Deleting…" : "Delete test file from Hetzner"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|