524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback, type ChangeEvent, type FormEvent } from "react"
|
|
import { ReloadIcon } from "@radix-ui/react-icons"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import type { NostrEvent } from "nostr-tools"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { signEvent } from "@/lib/utils"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { Copy, Check, ExternalLink, FileImage, Clock, Database, ArrowLeft } from "lucide-react"
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { Spinner } from "./spinner"
|
|
|
|
interface UploadResponse {
|
|
url: string
|
|
size: number
|
|
type: string
|
|
sha256: string
|
|
uploaded: number
|
|
nip94: {
|
|
tags: string[][]
|
|
content: string
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: number, decimals = 2) {
|
|
if (bytes === 0) return "0 Bytes"
|
|
|
|
const k = 1024
|
|
const dm = decimals < 0 ? 0 : decimals
|
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
|
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
|
|
}
|
|
|
|
function formatDate(timestamp: number) {
|
|
return new Date(timestamp * 1000).toLocaleString()
|
|
}
|
|
|
|
const CopyButton = ({ text }: { text: string }) => {
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const handleCopy = () => {
|
|
navigator.clipboard.writeText(text)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCopy}>
|
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
const UploadResponseView = ({ data, onReset }: { data: UploadResponse; onReset: () => void }) => {
|
|
const dimensions = data.nip94.tags.find((tag) => tag[0] === "dim")?.[1] || ""
|
|
const blurhash = data.nip94.tags.find((tag) => tag[0] === "blurhash")?.[1] || ""
|
|
const thumbUrl = data.nip94.tags.find((tag) => tag[0] === "thumb")?.[1] || ""
|
|
|
|
return (
|
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
|
<div className="flex items-center">
|
|
<Button variant="ghost" size="sm" onClick={onReset} className="gap-1">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Upload Another
|
|
</Button>
|
|
</div>
|
|
|
|
<Card className="w-full">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Upload Successful</CardTitle>
|
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
|
{data.type}
|
|
</Badge>
|
|
</div>
|
|
<CardDescription>File uploaded on {formatDate(data.uploaded)}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
<div className="flex-1">
|
|
<img
|
|
src={data.url || "/placeholder.svg"}
|
|
alt="Uploaded image"
|
|
className="rounded-md w-full h-auto object-cover max-h-64"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center text-sm text-muted-foreground">
|
|
<FileImage className="mr-2 h-4 w-4" />
|
|
File Size
|
|
</div>
|
|
<div className="font-medium">{formatBytes(data.size)}</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center text-sm text-muted-foreground">
|
|
<Clock className="mr-2 h-4 w-4" />
|
|
Upload Time
|
|
</div>
|
|
<div className="font-medium">{formatDate(data.uploaded)}</div>
|
|
</div>
|
|
|
|
{dimensions && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center text-sm text-muted-foreground">
|
|
<Database className="mr-2 h-4 w-4" />
|
|
Dimensions
|
|
</div>
|
|
<div className="font-medium">{dimensions}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="url" className="w-full">
|
|
<TabsList className="grid grid-cols-3">
|
|
<TabsTrigger value="url">URL</TabsTrigger>
|
|
<TabsTrigger value="hash">Hash</TabsTrigger>
|
|
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="url" className="space-y-4">
|
|
<div className="mt-2 space-y-2">
|
|
<Label>Image URL</Label>
|
|
<div className="flex items-center">
|
|
<code className="bg-muted p-2 rounded text-xs flex-1 overflow-x-auto">{data.url}</code>
|
|
<div className="flex ml-2">
|
|
<CopyButton text={data.url} />
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" asChild>
|
|
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{thumbUrl && (
|
|
<div className="mt-2 space-y-2">
|
|
<Label>Thumbnail URL</Label>
|
|
<div className="flex items-center">
|
|
<code className="bg-muted p-2 rounded text-xs flex-1 overflow-x-auto">{thumbUrl}</code>
|
|
<div className="flex ml-2">
|
|
<CopyButton text={thumbUrl} />
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" asChild>
|
|
<a href={thumbUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="hash" className="space-y-4">
|
|
<div className="mt-2 space-y-2">
|
|
<Label>SHA256</Label>
|
|
<div className="flex items-center">
|
|
<code className="bg-muted p-2 rounded text-xs flex-1 overflow-x-auto">{data.sha256}</code>
|
|
<CopyButton text={data.sha256} />
|
|
</div>
|
|
</div>
|
|
|
|
{blurhash && (
|
|
<div className="mt-2 space-y-2">
|
|
<Label>Blurhash</Label>
|
|
<div className="flex items-center">
|
|
<code className="bg-muted p-2 rounded text-xs flex-1 overflow-x-auto">{blurhash}</code>
|
|
<CopyButton text={blurhash} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="metadata" className="space-y-4">
|
|
<div className="mt-2">
|
|
<Label className="mb-2 block">NIP94 Tags</Label>
|
|
<div className="grid gap-2">
|
|
{data.nip94.tags.map((tag, index) => (
|
|
<div key={index} className="flex items-center bg-muted/50 p-2 rounded">
|
|
<Badge variant="outline" className="mr-2">
|
|
{tag[0]}
|
|
</Badge>
|
|
<code className="text-xs flex-1 overflow-x-auto">{tag[1]}</code>
|
|
<CopyButton text={tag[1]} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex justify-end">
|
|
<Button variant="outline" className="mr-2" asChild>
|
|
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
|
Open Image
|
|
</a>
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
const markdown = ``
|
|
navigator.clipboard.writeText(markdown)
|
|
alert("Markdown copied to clipboard!")
|
|
}}
|
|
>
|
|
Copy Markdown
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function calculateBlurhash(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const canvas = document.createElement("canvas")
|
|
const ctx = canvas.getContext("2d")
|
|
const img = new Image()
|
|
img.crossOrigin = "anonymous"
|
|
img.onload = () => {
|
|
canvas.width = 32
|
|
canvas.height = 32
|
|
ctx?.drawImage(img, 0, 0, 32, 32)
|
|
const imageData = ctx?.getImageData(0, 0, 32, 32)
|
|
if (imageData) {
|
|
// Mock blurhash calculation - in a real app you'd use the blurhash library
|
|
const mockBlurhash = "LGF5?xYk^6#M@-5c,1J5@[or[Q6."
|
|
resolve(mockBlurhash)
|
|
} else {
|
|
reject(new Error("Failed to get image data"))
|
|
}
|
|
}
|
|
img.onerror = reject
|
|
img.src = URL.createObjectURL(file)
|
|
})
|
|
}
|
|
|
|
async function calculateSHA256(file: File): Promise<string> {
|
|
// In a browser environment, we'd use the Web Crypto API
|
|
// This is a simplified mock implementation
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
// Generate a random SHA256-like hash for demo purposes
|
|
const mockHash = Array.from({ length: 64 }, () => "0123456789abcdef"[Math.floor(Math.random() * 16)]).join("")
|
|
resolve(mockHash)
|
|
}, 500)
|
|
})
|
|
}
|
|
|
|
const UploadComponent = () => {
|
|
const { createHash } = require("crypto")
|
|
const loginType = typeof window !== "undefined" ? window.localStorage.getItem("loginType") : null
|
|
const [previewUrl, setPreviewUrl] = useState("")
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [uploadedNoteId, setUploadedNoteId] = useState("")
|
|
const [retryCount, setRetryCount] = useState(0)
|
|
const [shouldFetch, setShouldFetch] = useState(false)
|
|
const [serverChoice, setServerChoice] = useState("blossom.band")
|
|
const [events, setEvents] = useState<any[]>([])
|
|
const [isNoteLoading, setIsNoteLoading] = useState(false)
|
|
const [uploadResponse, setUploadResponse] = useState<UploadResponse | null>(null)
|
|
const [showUploadForm, setShowUploadForm] = useState(true)
|
|
|
|
useEffect(() => {
|
|
if (uploadedNoteId) {
|
|
setShouldFetch(true)
|
|
}
|
|
}, [uploadedNoteId])
|
|
|
|
useEffect(() => {
|
|
let timeoutId: NodeJS.Timeout
|
|
|
|
if (shouldFetch && events.length === 0 && !isNoteLoading) {
|
|
setIsNoteLoading(true)
|
|
|
|
// Simulate fetching events
|
|
timeoutId = setTimeout(() => {
|
|
setIsNoteLoading(false)
|
|
// After a few retries, simulate finding the event
|
|
if (retryCount >= 2) {
|
|
setEvents([{ id: uploadedNoteId }])
|
|
} else {
|
|
setRetryCount((prevCount) => prevCount + 1)
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
return () => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
}, [shouldFetch, events, isNoteLoading, retryCount, uploadedNoteId])
|
|
|
|
const handleRetry = useCallback(() => {
|
|
setRetryCount((prevCount) => prevCount + 1)
|
|
setShouldFetch(false)
|
|
setTimeout(() => setShouldFetch(true), 100)
|
|
}, [])
|
|
|
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0]
|
|
if (file) {
|
|
const url = URL.createObjectURL(file)
|
|
setPreviewUrl(url)
|
|
|
|
return () => URL.revokeObjectURL(url)
|
|
}
|
|
}
|
|
|
|
const handleServerChange = (value: string) => {
|
|
setServerChoice(value)
|
|
}
|
|
|
|
const resetUpload = () => {
|
|
setUploadResponse(null)
|
|
setShowUploadForm(true)
|
|
setPreviewUrl("")
|
|
setUploadedNoteId("")
|
|
setEvents([])
|
|
setRetryCount(0)
|
|
setShouldFetch(false)
|
|
}
|
|
|
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
setIsLoading(true)
|
|
|
|
const formData = new FormData(event.currentTarget)
|
|
const desc = formData.get("description") as string
|
|
const file = formData.get("file") as File
|
|
let sha256 = ""
|
|
let finalNoteContent = desc
|
|
let finalFileUrl = ""
|
|
console.log("File:", file)
|
|
|
|
if (!desc && !file.size) {
|
|
alert("Please enter a description and/or upload a file")
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
// If file is present, upload it to the media server
|
|
if (file) {
|
|
const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
|
reader.onerror = () => reject(reader.error)
|
|
reader.readAsArrayBuffer(file)
|
|
})
|
|
}
|
|
|
|
try {
|
|
const arrayBuffer = await readFileAsArrayBuffer(file)
|
|
const hashBuffer = createHash("sha256").update(Buffer.from(arrayBuffer)).digest()
|
|
sha256 = hashBuffer.toString("hex")
|
|
|
|
const unixNow = () => Math.floor(Date.now() / 1000)
|
|
const newExpirationValue = () => (unixNow() + 60 * 5).toString()
|
|
|
|
const pubkey = window.localStorage.getItem("pubkey")
|
|
const createdAt = Math.floor(Date.now() / 1000)
|
|
|
|
// Create auth event for blossom auth via nostr
|
|
const authEvent: NostrEvent = {
|
|
kind: 24242,
|
|
content: "File upload",
|
|
created_at: createdAt,
|
|
tags: [
|
|
["t", "media"],
|
|
["x", sha256],
|
|
["expiration", newExpirationValue()],
|
|
],
|
|
pubkey: "", // Add a placeholder for pubkey
|
|
id: "", // Add a placeholder for id
|
|
sig: "", // Add a placeholder for sig
|
|
}
|
|
|
|
console.log(authEvent)
|
|
|
|
// Sign auth event
|
|
const authEventSigned = (await signEvent(loginType, authEvent)) as NostrEvent
|
|
// authEventSigned as base64 encoded string
|
|
const authString = Buffer.from(JSON.stringify(authEventSigned)).toString("base64")
|
|
|
|
const blossomServer = "https://" + serverChoice
|
|
|
|
await fetch(blossomServer + "/media", {
|
|
method: "PUT",
|
|
body: file,
|
|
headers: { authorization: "Nostr " + authString },
|
|
}).then(async (res) => {
|
|
if (res.ok) {
|
|
const responseText = await res.text()
|
|
const responseJson = JSON.parse(responseText)
|
|
finalFileUrl = responseJson.url
|
|
sha256 = responseJson.sha256
|
|
|
|
// Set the upload response data
|
|
setUploadResponse(responseJson)
|
|
|
|
let blurhash = ""
|
|
if (file && file.type.startsWith("image/")) {
|
|
try {
|
|
blurhash = await calculateBlurhash(file)
|
|
} catch (error) {
|
|
console.error("Error calculating blurhash:", error)
|
|
}
|
|
}
|
|
|
|
if (finalFileUrl) {
|
|
const image = new Image()
|
|
image.src = URL.createObjectURL(file)
|
|
await new Promise((resolve) => {
|
|
image.onload = resolve
|
|
})
|
|
|
|
finalNoteContent = desc
|
|
}
|
|
|
|
setIsLoading(false)
|
|
if (finalFileUrl != null) {
|
|
setShowUploadForm(false)
|
|
setShouldFetch(true)
|
|
setRetryCount(0)
|
|
}
|
|
} else {
|
|
throw new Error("Failed to upload file: " + (await res.text()))
|
|
}
|
|
})
|
|
} catch (error) {
|
|
alert(error)
|
|
console.error("Error reading file:", error)
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="w-full mx-auto">
|
|
{showUploadForm ? (
|
|
<Card className="w-full">
|
|
<CardHeader>
|
|
<CardTitle>Upload Image</CardTitle>
|
|
<CardDescription>Select an image to upload to {serverChoice}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form className="space-y-4" onSubmit={onSubmit}>
|
|
<div className="grid w-full items-center gap-1.5">
|
|
<Label htmlFor="file">Select File</Label>
|
|
<Input id="file" name="file" type="file" accept="image/*" onChange={handleFileChange} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 w-full items-center gap-1.5">
|
|
<Label htmlFor="server-select">Upload to</Label>
|
|
<Select onValueChange={handleServerChange} value={serverChoice}>
|
|
<SelectTrigger className="w-full" id="server-select">
|
|
<SelectValue placeholder={serverChoice} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="blossom.band">blossom.band</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{previewUrl && (
|
|
<div className="mt-4 rounded-md overflow-hidden">
|
|
<img
|
|
src={previewUrl || "/placeholder.svg"}
|
|
alt="Preview"
|
|
className="w-full max-h-80 object-contain"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<Button className="w-full" disabled>
|
|
Uploading... <ReloadIcon className="ml-2 h-4 w-4 animate-spin" />
|
|
</Button>
|
|
) : (
|
|
<Button className="w-full" type="submit">
|
|
Upload
|
|
</Button>
|
|
)}
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
) : uploadResponse ? (
|
|
<UploadResponseView data={uploadResponse} onReset={resetUpload} />
|
|
) : (
|
|
<Card className="w-full">
|
|
<CardContent className="flex flex-col items-center justify-center p-8">
|
|
<Spinner />
|
|
<p className="mt-4">Processing upload...</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default UploadComponent
|