feat: mirror tool
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-06-18 15:17:33 +01:00
parent 84b475d11a
commit 84be9c16bb
4 changed files with 498 additions and 354 deletions

View File

@ -7,7 +7,7 @@ function App() {
return ( return (
<Router> <Router>
<div className="min-h-screen bg-gray-900"> <div className="min-h-screen bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-lg:px-6">
<Header /> <Header />
<main className="py-8"> <main className="py-8">
<Routes> <Routes>

View File

@ -18,11 +18,19 @@ interface MirrorSuggestionsProps {
servers: string[]; servers: string[];
} }
interface MirrorProgress {
total: number;
completed: number;
failed: number;
errors: string[];
}
export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) { export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
const [suggestions, setSuggestions] = useState<FileMirrorSuggestion[]>([]); const [suggestions, setSuggestions] = useState<FileMirrorSuggestion[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [mirroring, setMirroring] = useState<Set<string>>(new Set()); const [mirroring, setMirroring] = useState<Set<string>>(new Set());
const [mirrorAllProgress, setMirrorAllProgress] = useState<MirrorProgress | null>(null);
const pub = usePublisher(); const pub = usePublisher();
const login = useLogin(); const login = useLogin();
@ -35,16 +43,23 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
async function fetchSuggestions() { async function fetchSuggestions() {
if (!pub || !login?.pubkey) return; if (!pub || !login?.pubkey) return;
if (loading) return;
try { try {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
// Capture the servers list at the start to avoid race conditions
const serverList = [...servers];
if (serverList.length <= 1) {
setLoading(false);
return;
}
const fileMap: Map<string, FileMirrorSuggestion> = new Map(); const fileMap: Map<string, FileMirrorSuggestion> = new Map();
// Fetch files from each server // Fetch files from each server
for (const serverUrl of servers) { for (const serverUrl of serverList) {
try { try {
const blossom = new Blossom(serverUrl, pub); const blossom = new Blossom(serverUrl, pub);
const files = await blossom.list(login.pubkey); const files = await blossom.list(login.pubkey);
@ -70,9 +85,9 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
} }
} }
// Determine missing servers for each file // Determine missing servers for each file using the captured server list
for (const suggestion of fileMap.values()) { for (const suggestion of fileMap.values()) {
for (const serverUrl of servers) { for (const serverUrl of serverList) {
if (!suggestion.available_on.includes(serverUrl)) { if (!suggestion.available_on.includes(serverUrl)) {
suggestion.missing_from.push(serverUrl); suggestion.missing_from.push(serverUrl);
} }
@ -96,43 +111,96 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
} }
} }
async function mirrorFile(suggestion: FileMirrorSuggestion, targetServer: string) { async function mirrorAll() {
if (!pub) return; if (!pub || suggestions.length === 0) return;
const mirrorKey = `${suggestion.sha256}-${targetServer}`; // Calculate total operations needed
setMirroring(prev => new Set(prev.add(mirrorKey))); const totalOperations = suggestions.reduce((total, suggestion) =>
total + suggestion.missing_from.length, 0
);
try { setMirrorAllProgress({
const blossom = new Blossom(targetServer, pub); total: totalOperations,
await blossom.mirror(suggestion.url); completed: 0,
failed: 0,
errors: []
});
// Update suggestions by removing this server from missing_from let completed = 0;
setSuggestions(prev => let failed = 0;
prev.map(s => const errors: string[] = [];
s.sha256 === suggestion.sha256
? { // Mirror all files to all missing servers
...s, for (const suggestion of suggestions) {
available_on: [...s.available_on, targetServer], for (const targetServer of suggestion.missing_from) {
missing_from: s.missing_from.filter(server => server !== targetServer) try {
} const blossom = new Blossom(targetServer, pub);
: s await blossom.mirror(suggestion.url);
).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers completed++;
);
} catch (e) { setMirrorAllProgress(prev => prev ? {
if (e instanceof Error) { ...prev,
setError(`Failed to mirror file: ${e.message}`); completed: completed,
} else { failed: failed
setError("Failed to mirror file"); } : null);
// Update suggestions in real-time
setSuggestions(prev =>
prev.map(s =>
s.sha256 === suggestion.sha256
? {
...s,
available_on: [...s.available_on, targetServer],
missing_from: s.missing_from.filter(server => server !== targetServer)
}
: s
).filter(s => s.missing_from.length > 0)
);
} catch (e) {
failed++;
const errorMessage = e instanceof Error ? e.message : "Unknown error";
const serverHost = new URL(targetServer).hostname;
errors.push(`${serverHost}: ${errorMessage}`);
setMirrorAllProgress(prev => prev ? {
...prev,
completed: completed,
failed: failed,
errors: [...errors]
} : null);
}
} }
} finally {
setMirroring(prev => {
const newSet = new Set(prev);
newSet.delete(mirrorKey);
return newSet;
});
} }
// Keep progress visible for a moment before clearing
setTimeout(() => {
setMirrorAllProgress(null);
}, 3000);
} }
// Calculate coverage statistics
const totalFiles = suggestions.length;
const totalMirrorOperations = suggestions.reduce((total, suggestion) =>
total + suggestion.missing_from.length, 0
);
const totalSize = suggestions.reduce((total, suggestion) => total + suggestion.size, 0);
// Calculate coverage per server
const serverCoverage = servers.map(serverUrl => {
const filesOnServer = suggestions.filter(s => s.available_on.includes(serverUrl)).length;
const totalFilesAcrossAllServers = new Set(suggestions.map(s => s.sha256)).size;
const coveragePercentage = totalFilesAcrossAllServers > 0 ?
Math.round((filesOnServer / totalFilesAcrossAllServers) * 100) : 100;
return {
url: serverUrl,
hostname: new URL(serverUrl).hostname,
filesCount: filesOnServer,
totalFiles: totalFilesAcrossAllServers,
coveragePercentage
};
});
if (servers.length <= 1) { if (servers.length <= 1) {
return null; // No suggestions needed for single server return null; // No suggestions needed for single server
} }
@ -171,66 +239,133 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
return ( return (
<div className="card"> <div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3> <h3 className="text-lg font-semibold mb-4">Mirror Coverage</h3>
<p className="text-gray-400 mb-6">
The following files are missing from some of your servers and can be mirrored:
</p>
<div className="space-y-4"> {/* Coverage Summary */}
{suggestions.map((suggestion) => ( <div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-6">
<div key={suggestion.sha256} className="bg-gray-800 border border-gray-700 rounded-lg p-4"> <div className="grid grid-cols-3 gap-4 text-center">
<div className="flex items-start justify-between mb-3"> <div>
<div className="flex-1"> <div className="text-2xl font-bold text-blue-400">{totalFiles}</div>
<p className="text-sm font-medium text-gray-300 mb-1"> <div className="text-xs text-gray-400">Files to Mirror</div>
File: {suggestion.sha256} </div>
</p> <div>
<p className="text-xs text-gray-400"> <div className="text-2xl font-bold text-orange-400">{totalMirrorOperations}</div>
Size: {FormatBytes(suggestion.size)} <div className="text-xs text-gray-400">Operations Needed</div>
{suggestion.mime_type && ` • Type: ${suggestion.mime_type}`} </div>
</p> <div>
<div className="text-2xl font-bold text-green-400">{FormatBytes(totalSize)}</div>
<div className="text-xs text-gray-400">Total Size</div>
</div>
</div>
</div>
{/* Server Coverage */}
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-6">
<h4 className="text-sm font-semibold text-gray-300 mb-3">Coverage by Server</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{serverCoverage.map((server) => (
<div key={server.url} className="bg-gray-750 border border-gray-600 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300 truncate">
{server.hostname}
</span>
<span
className={`text-sm font-semibold ${server.coveragePercentage === 100
? "text-green-400"
: server.coveragePercentage >= 80
? "text-yellow-400"
: "text-red-400"
}`}
>
{server.coveragePercentage}%
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${server.coveragePercentage === 100
? "bg-green-500"
: server.coveragePercentage >= 80
? "bg-yellow-500"
: "bg-red-500"
}`}
style={{
width: `${server.coveragePercentage}%`,
}}
></div>
</div>
<div className="text-xs text-gray-400 text-center">
{server.filesCount} / {server.totalFiles} files
</div> </div>
</div> </div>
))}
</div>
</div>
<div className="space-y-2"> {/* Mirror All Section */}
<div> {!mirrorAllProgress ? (
<p className="text-xs text-green-400 mb-1">Available on:</p> <div className="text-center">
<div className="flex flex-wrap gap-1"> <p className="text-gray-400 mb-4">
{suggestion.available_on.map((server) => ( {totalFiles} files need to be synchronized across your servers
<span key={server} className="text-xs bg-green-900/30 text-green-300 px-2 py-1 rounded"> </p>
{new URL(server).hostname} <Button
</span> onClick={mirrorAll}
))} className="btn-primary"
</div> disabled={totalMirrorOperations === 0}
</div> >
Mirror Everything
<div> </Button>
<p className="text-xs text-red-400 mb-1">Missing from:</p> </div>
<div className="flex flex-wrap gap-2"> ) : (
{suggestion.missing_from.map((server) => { <div className="space-y-4">
const mirrorKey = `${suggestion.sha256}-${server}`; {/* Progress Bar */}
const isMirroring = mirroring.has(mirrorKey); <div>
<div className="flex justify-between text-sm mb-2">
return ( <span className="text-gray-400">Progress</span>
<div key={server} className="flex items-center gap-2"> <span className="text-gray-400">
<span className="text-xs bg-red-900/30 text-red-300 px-2 py-1 rounded"> {mirrorAllProgress.completed + mirrorAllProgress.failed} / {mirrorAllProgress.total}
{new URL(server).hostname} </span>
</span> </div>
<Button <div className="w-full bg-gray-700 rounded-full h-2">
onClick={() => mirrorFile(suggestion, server)} <div
disabled={isMirroring} className="bg-blue-500 h-2 rounded-full transition-all duration-300"
className="btn-primary text-xs py-1 px-2" style={{
> width: `${((mirrorAllProgress.completed + mirrorAllProgress.failed) / mirrorAllProgress.total) * 100}%`
{isMirroring ? "Mirroring..." : "Mirror"} }}
</Button> />
</div>
);
})}
</div>
</div>
</div> </div>
</div> </div>
))}
</div> {/* Status Summary */}
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div className="text-green-400 font-semibold">{mirrorAllProgress.completed}</div>
<div className="text-gray-400">Completed</div>
</div>
<div>
<div className="text-red-400 font-semibold">{mirrorAllProgress.failed}</div>
<div className="text-gray-400">Failed</div>
</div>
<div>
<div className="text-gray-400 font-semibold">
{mirrorAllProgress.total - mirrorAllProgress.completed - mirrorAllProgress.failed}
</div>
<div className="text-gray-400">Remaining</div>
</div>
</div>
{/* Errors */}
{mirrorAllProgress.errors.length > 0 && (
<div className="bg-red-900/20 border border-red-800 rounded-lg p-3">
<h4 className="text-red-400 font-semibold mb-2">Errors ({mirrorAllProgress.errors.length})</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{mirrorAllProgress.errors.map((error, index) => (
<div key={index} className="text-red-300 text-xs">{error}</div>
))}
</div>
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@ -43,7 +43,7 @@ export default function Header() {
return ( return (
<header className="border-b border-gray-700 bg-gray-800 w-full"> <header className="border-b border-gray-700 bg-gray-800 w-full">
<div className="px-4 sm:px-6 lg:px-8 flex justify-between items-center py-4"> <div className="px-4 flex justify-between items-center py-4">
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
<Link to="/"> <Link to="/">
<div className="text-2xl font-bold text-gray-100 hover:text-blue-400 transition-colors"> <div className="text-2xl font-bold text-gray-100 hover:text-blue-400 transition-colors">

View File

@ -15,7 +15,7 @@ import { FormatBytes, ServerUrl } from "../const";
import { UploadProgress } from "../upload/progress"; import { UploadProgress } from "../upload/progress";
export default function Upload() { export default function Upload() {
const [noCompress, setNoCompress] = useState(false); const [stripMetadata, setStripMetadata] = useState(true);
const [self, setSelf] = useState<AdminSelf>(); const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<BlobDescriptor>>([]); const [results, setResults] = useState<Array<BlobDescriptor>>([]);
@ -50,8 +50,8 @@ export default function Upload() {
}; };
const uploader = new Blossom(ServerUrl, pub); const uploader = new Blossom(ServerUrl, pub);
// Use compression by default for video and image files, unless explicitly disabled // Use compression for video and image files when metadata stripping is enabled
const useCompression = shouldCompress(file) && !noCompress; const useCompression = shouldCompress(file) && stripMetadata;
const result = useCompression const result = useCompression
? await uploader.media(file, onProgress) ? await uploader.media(file, onProgress)
: await uploader.upload(file, onProgress); : await uploader.upload(file, onProgress);
@ -159,294 +159,303 @@ export default function Upload() {
} }
return ( return (
<div className="max-w-4xl mx-auto space-y-8"> <div className="w-full px-4">
{error && ( {error && (
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg"> <div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg mb-6">
{error} {error}
</div> </div>
)} )}
<div className="card"> <div className="flex flex-wrap gap-6">
<h2 className="text-xl font-semibold mb-6">Upload Files</h2> {/* Upload Widget */}
<div className="card flex-1 min-w-80">
<h2 className="text-xl font-semibold mb-6">Upload Files</h2>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<label className="flex items-center cursor-pointer"> <label className="flex items-center cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={noCompress} checked={stripMetadata}
onChange={(e) => setNoCompress(e.target.checked)} onChange={(e) => setStripMetadata(e.target.checked)}
className="mr-2" className="mr-2"
/>
<span className="text-sm font-medium text-gray-300">
Strip metadata (for images)
</span>
</label>
</div>
{/* Upload Progress */}
{isUploading && uploadProgress && (
<ProgressBar
progress={uploadProgress}
/> />
<span className="text-sm font-medium text-gray-300"> )}
Disable Compression (for images and videos)
</span>
</label>
</div>
{/* Upload Progress */} <div className="flex gap-4">
{isUploading && uploadProgress && ( <Button
<ProgressBar onClick={handleFileSelection}
progress={uploadProgress} className="btn-primary flex-1"
/> disabled={isUploading}
)} >
{isUploading ? "Uploading..." : "Select Files to Upload"}
<div className="flex gap-4"> </Button>
<Button </div>
onClick={handleFileSelection}
className="btn-primary flex-1"
disabled={isUploading}
>
{isUploading ? "Uploading..." : "Select Files to Upload"}
</Button>
</div> </div>
</div> </div>
</div>
{self && ( {/* Storage Usage Widget */}
<div className="card max-w-2xl mx-auto"> {self && (
<h3 className="text-lg font-semibold mb-4">Storage Usage</h3> <div className="card flex-1 min-w-80">
<div className="space-y-4"> <h3 className="text-lg font-semibold mb-4">Storage Usage</h3>
{/* File Count */} <div className="space-y-4">
<div className="flex justify-between text-sm"> {/* File Count */}
<span>Files:</span> <div className="flex justify-between text-sm">
<span className="font-medium"> <span>Files:</span>
{self.file_count.toLocaleString()} <span className="font-medium">
</span> {self.file_count.toLocaleString()}
</div> </span>
</div>
{/* Total Usage */} {/* Total Usage */}
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Total Size:</span> <span>Total Size:</span>
<span className="font-medium"> <span className="font-medium">
{FormatBytes(self.total_size)} {FormatBytes(self.total_size)}
</span> </span>
</div> </div>
{/* Only show quota information if available */} {/* Only show quota information if available */}
{self.total_available_quota && self.total_available_quota > 0 && ( {self.total_available_quota && self.total_available_quota > 0 && (
<> <>
{/* Progress Bar */} {/* Progress Bar */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Quota Used:</span>
<span className="font-medium">
{FormatBytes(self.total_size)} of{" "}
{FormatBytes(self.total_available_quota)}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${self.total_size / self.total_available_quota > 0.8
? "bg-red-500"
: self.total_size / self.total_available_quota > 0.6
? "bg-yellow-500"
: "bg-green-500"
}`}
style={{
width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`,
}}
></div>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>
{(
(self.total_size / self.total_available_quota) *
100
).toFixed(1)}
% used
</span>
<span
className={`${self.total_size / self.total_available_quota > 0.8
? "text-red-400"
: self.total_size / self.total_available_quota > 0.6
? "text-yellow-400"
: "text-green-400"
}`}
>
{FormatBytes(
Math.max(
0,
self.total_available_quota - self.total_size,
),
)}{" "}
remaining
</span>
</div>
</div>
{/* Quota Breakdown - excluding free quota */}
<div className="space-y-2 pt-2 border-t border-gray-700">
{(self.quota ?? 0) > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Paid Quota:</span> <span>Quota Used:</span>
<span className="font-medium"> <span className="font-medium">
{FormatBytes(self.quota!)} {FormatBytes(self.total_size)} of{" "}
{FormatBytes(self.total_available_quota)}
</span> </span>
</div> </div>
)} <div className="w-full bg-gray-700 rounded-full h-2.5">
{(self.paid_until ?? 0) > 0 && ( <div
<div className="flex justify-between text-sm"> className={`h-2.5 rounded-full transition-all duration-300 ${self.total_size / self.total_available_quota > 0.8
<span>Expires:</span> ? "bg-red-500"
<div className="text-right"> : self.total_size / self.total_available_quota > 0.6
<div className="font-medium"> ? "bg-yellow-500"
{new Date( : "bg-green-500"
self.paid_until! * 1000, }`}
).toLocaleDateString()} style={{
</div> width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`,
<div className="text-xs text-gray-400"> }}
{(() => { ></div>
const now = Date.now() / 1000; </div>
const daysLeft = Math.max( <div className="flex justify-between text-xs text-gray-400">
0, <span>
Math.ceil( {(
(self.paid_until! - now) / (24 * 60 * 60), (self.total_size / self.total_available_quota) *
), 100
); ).toFixed(1)}
return daysLeft > 0 % used
? `${daysLeft} days left` </span>
: "Expired"; <span
})()} className={`${self.total_size / self.total_available_quota > 0.8
? "text-red-400"
: self.total_size / self.total_available_quota > 0.6
? "text-yellow-400"
: "text-green-400"
}`}
>
{FormatBytes(
Math.max(
0,
self.total_available_quota - self.total_size,
),
)}{" "}
remaining
</span>
</div>
</div>
{/* Quota Breakdown - excluding free quota */}
<div className="space-y-2 pt-2 border-t border-gray-700">
{(self.quota ?? 0) > 0 && (
<div className="flex justify-between text-sm">
<span>Paid Quota:</span>
<span className="font-medium">
{FormatBytes(self.quota!)}
</span>
</div>
)}
{(self.paid_until ?? 0) > 0 && (
<div className="flex justify-between text-sm">
<span>Expires:</span>
<div className="text-right">
<div className="font-medium">
{new Date(
self.paid_until! * 1000,
).toLocaleDateString()}
</div>
<div className="text-xs text-gray-400">
{(() => {
const now = Date.now() / 1000;
const daysLeft = Math.max(
0,
Math.ceil(
(self.paid_until! - now) / (24 * 60 * 60),
),
);
return daysLeft > 0
? `${daysLeft} days left`
: "Expired";
})()}
</div>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div> </>
</> )}
</div>
<Button
onClick={() => setShowPaymentFlow(!showPaymentFlow)}
className="btn-primary w-full mt-4"
>
{showPaymentFlow ? "Hide" : "Show"} Payment Options
</Button>
</div>
)}
{/* Payment Flow Widget */}
{showPaymentFlow && pub && (
<div className="card flex-1 min-w-80">
<PaymentFlow
route96={new Route96(ServerUrl, pub)}
onPaymentRequested={(pr) => {
console.log("Payment requested:", pr);
}}
userInfo={self}
/>
</div>
)}
{/* Mirror Suggestions Widget */}
{blossomServers && blossomServers.length > 1 && (
<div className="w-full">
<MirrorSuggestions
servers={blossomServers}
/>
</div>
)}
{/* Files Widget */}
<div className="card w-full">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Your Files</h2>
{!listedFiles && (
<Button onClick={() => listUploads(0)} className="btn-primary">
Load Files
</Button>
)} )}
</div> </div>
<Button
onClick={() => setShowPaymentFlow(!showPaymentFlow)}
className="btn-primary w-full mt-4"
>
{showPaymentFlow ? "Hide" : "Show"} Payment Options
</Button>
</div>
)}
{showPaymentFlow && pub && ( {listedFiles && (
<div className="card"> <FileList
<PaymentFlow files={listedFiles.files}
route96={new Route96(ServerUrl, pub)} pages={Math.ceil(listedFiles.total / listedFiles.count)}
onPaymentRequested={(pr) => { page={listedFiles.page}
console.log("Payment requested:", pr); onPage={(x) => setListedPage(x)}
}} onDelete={async (x) => {
userInfo={self} await deleteFile(x);
/> await listUploads(listedPage);
</div> }}
)} />
{/* Mirror Suggestions */}
{blossomServers && blossomServers.length > 1 && (
<MirrorSuggestions
servers={blossomServers}
/>
)}
<div className="card">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Your Files</h2>
{!listedFiles && (
<Button onClick={() => listUploads(0)} className="btn-primary">
Load Files
</Button>
)} )}
</div> </div>
{listedFiles && ( {/* Upload Results Widget */}
<FileList {results.length > 0 && (
files={listedFiles.files} <div className="card w-full">
pages={Math.ceil(listedFiles.total / listedFiles.count)} <h3 className="text-lg font-semibold mb-4">Upload Results</h3>
page={listedFiles.page} <div className="space-y-4">
onPage={(x) => setListedPage(x)} {results.map((result, index) => (
onDelete={async (x) => { <div
await deleteFile(x); key={index}
await listUploads(listedPage); className="bg-gray-800 border border-gray-700 rounded-lg p-4"
}} >
/> <div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-green-400 mb-1">
Upload Successful
</h4>
<p className="text-sm text-gray-400">
{new Date(
(result.uploaded || Date.now() / 1000) * 1000,
).toLocaleString()}
</p>
</div>
<div className="text-right">
<span className="text-xs bg-blue-900/50 text-blue-300 px-2 py-1 rounded">
{result.type || "Unknown type"}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-400">File Size</p>
<p className="font-medium">
{FormatBytes(result.size || 0)}
</p>
</div>
</div>
<div className="space-y-2">
{result.url && (
<div>
<p className="text-sm text-gray-400 mb-1">File URL</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-900 text-green-400 px-2 py-1 rounded flex-1 overflow-hidden">
{result.url}
</code>
<button
onClick={() =>
navigator.clipboard.writeText(result.url!)
}
className="text-xs bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded transition-colors"
title="Copy URL"
>
Copy
</button>
</div>
</div>
)}
<div>
<p className="text-sm text-gray-400 mb-1">
File Hash (SHA256)
</p>
<code className="text-xs bg-gray-900 text-gray-400 px-2 py-1 rounded block overflow-hidden">
{result.sha256}
</code>
</div>
</div>
<details className="mt-4">
<summary className="text-sm text-gray-400 cursor-pointer hover:text-gray-300">
Show raw JSON data
</summary>
<pre className="text-xs bg-gray-900 text-gray-300 p-3 rounded mt-2 overflow-auto">
{JSON.stringify(result, undefined, 2)}
</pre>
</details>
</div>
))}
</div>
</div>
)} )}
</div> </div>
{results.length > 0 && (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Upload Results</h3>
<div className="space-y-4">
{results.map((result, index) => (
<div
key={index}
className="bg-gray-800 border border-gray-700 rounded-lg p-4"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-green-400 mb-1">
Upload Successful
</h4>
<p className="text-sm text-gray-400">
{new Date(
(result.uploaded || Date.now() / 1000) * 1000,
).toLocaleString()}
</p>
</div>
<div className="text-right">
<span className="text-xs bg-blue-900/50 text-blue-300 px-2 py-1 rounded">
{result.type || "Unknown type"}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-400">File Size</p>
<p className="font-medium">
{FormatBytes(result.size || 0)}
</p>
</div>
</div>
<div className="space-y-2">
{result.url && (
<div>
<p className="text-sm text-gray-400 mb-1">File URL</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-900 text-green-400 px-2 py-1 rounded flex-1 overflow-hidden">
{result.url}
</code>
<button
onClick={() =>
navigator.clipboard.writeText(result.url!)
}
className="text-xs bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded transition-colors"
title="Copy URL"
>
Copy
</button>
</div>
</div>
)}
<div>
<p className="text-sm text-gray-400 mb-1">
File Hash (SHA256)
</p>
<code className="text-xs bg-gray-900 text-gray-400 px-2 py-1 rounded block overflow-hidden">
{result.sha256}
</code>
</div>
</div>
<details className="mt-4">
<summary className="text-sm text-gray-400 cursor-pointer hover:text-gray-300">
Show raw JSON data
</summary>
<pre className="text-xs bg-gray-900 text-gray-300 p-3 rounded mt-2 overflow-auto">
{JSON.stringify(result, undefined, 2)}
</pre>
</details>
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }