@@ -0,0 +1,177 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user