The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.
By: Mayne
Preview images, video, audio, pdf and edit text
import React, { useEffect, useState, useMemo } from "react"
import {
FileText, Image as ImageIcon, Video, Music, File as FileIcon,
Download, Save, AlertCircle, Loader2
} from "lucide-react"
import { Button } from "@/components/ui/button"
// File Handler Meta Configuration
export const meta = {
type: "fileHandler",
componentName: "FileHandler",
fileHandler: {
title: "Universal Viewer",
description: "Preview images, video, audio, pdf and edit text",
extensions: [
// Text/Code
".md", ".txt", ".json", ".js", ".jsx", ".ts", ".tsx", ".css", ".html", ".xml", ".yml", ".yaml",
// Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
// Media
".mp4", ".webm", ".mov", ".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
// Documents
".pdf"
],
icon: "👁️",
},
}
function useFilePathFromHash() {
const [filePath, setFilePath] = useState("")
useEffect(() => {
const updatePath = () => {
const hash = window.location.hash
const path = hash.startsWith("#") ? hash.substring(1) : hash
// Decode URL-encoded characters (e.g., %20 -> space)
setFilePath(decodeURIComponent(path))
}
updatePath()
window.addEventListener("hashchange", updatePath)
return () => window.removeEventListener("hashchange", updatePath)
}, [])
return filePath
}
// Helper to determine file category
const getFileType = (path) => {
if (!path) return "unknown"
const ext = path.split(".").pop()?.toLowerCase()
if (["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"].includes(ext)) return "image"
if (["mp4", "webm", "mov"].includes(ext)) return "video"
if (["mp3", "wav", "ogg", "flac", "m4a", "aac"].includes(ext)) return "audio"
if (["pdf"].includes(ext)) return "pdf"
if (["md", "txt", "json", "js", "jsx", "ts", "tsx", "css", "html", "xml", "yml", "yaml"].includes(ext)) return "text"
return "unknown"
}
export default function FileHandler() {
const [content, setContent] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const filePath = useFilePathFromHash()
const fileType = useMemo(() => getFileType(filePath), [filePath])
// Construct direct URL for media resources
const mediaUrl = filePath ? `/${filePath}` : ""
useEffect(() => {
const loadFile = async () => {
// Only read file content for text-based files
if (fileType !== "text") return
try {
setIsLoading(true)
const text = await eidos.currentSpace.fs.readFile(filePath, "utf8")
setContent(text)
setError("")
} catch (err: any) {
console.error("Error reading file:", err)
setError(err.message || "Failed to read file")
} finally {
setIsLoading(false)
}
}
if (filePath) {
loadFile()
}
}, [filePath, fileType])
const handleSave = async () => {
if (fileType !== "text") return
try {
await eidos.currentSpace.fs.writeFile(filePath, content, "utf8")
eidos.currentSpace.notify({
title: "Success",
description: "File saved",
})
} catch (err: any) {
console.error("Error saving file:", err)
setError(err.message || "Failed to save file")
eidos.currentSpace.notify({
title: "Error",
description: err.message || "Failed to save file",
})
}
}
const handleDownload = () => {
const link = document.createElement('a')
link.href = mediaUrl
link.download = filePath.split("/").pop() || "download"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const renderIcon = () => {
switch (fileType) {
case "image": return <ImageIcon className="w-4 h-4 mr-2" />
case "video": return <Video className="w-4 h-4 mr-2" />
case "audio": return <Music className="w-4 h-4 mr-2" />
case "text": return <FileText className="w-4 h-4 mr-2" />
default: return <FileIcon className="w-4 h-4 mr-2" />
}
}
const renderContent = () => {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading...
</div>
)
}
switch (fileType) {
case "image":
return (
<div className="flex h-full items-center justify-center bg-muted/10 p-4">
<img
src={mediaUrl}
alt="Preview"
className="max-w-full max-h-full object-contain shadow-sm rounded-md"
/>
</div>
)
case "video":
return (
<div className="flex h-full items-center justify-center bg-black p-4">
<video
src={mediaUrl}
controls
className="max-w-full max-h-full"
/>
</div>
)
case "audio":
return (
<div className="flex h-full items-center justify-center bg-muted/10">
<div className="bg-card p-8 rounded-xl shadow-sm border flex flex-col items-center gap-4 min-w-[300px]">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary">
<Music className="w-8 h-8" />
</div>
<div className="text-sm font-medium mb-2">{filePath.split("/").pop()}</div>
<audio src={mediaUrl} controls className="w-full" />
</div>
</div>
)
case "pdf":
return (
<iframe
src={mediaUrl}
className="w-full h-full border-none bg-white"
title="PDF Preview"
/>
)
case "text":
return (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-full p-4 resize-none border-none focus:outline-none font-mono text-sm bg-background text-foreground leading-relaxed"
placeholder="File content..."
spellCheck={false}
/>
)
default:
return (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground gap-2">
<AlertCircle className="w-8 h-8" />
<p>Preview not available for this file type.</p>
<Button variant="outline" onClick={handleDownload}>
Download to view
</Button>
</div>
)
}
}
if (error) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="text-destructive mb-2 flex items-center justify-center gap-2">
<AlertCircle className="w-4 h-4" /> Error
</div>
<div className="text-sm text-muted-foreground">{error}</div>
</div>
</div>
)
}
return (
<div className="flex h-screen flex-col bg-background">
{/* Header */}
<div className="border-b px-4 h-14 flex items-center justify-between bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center min-w-0">
<div className="text-muted-foreground">
{renderIcon()}
</div>
<div className="text-sm font-medium truncate select-all" title={filePath}>
{filePath.split("/").pop()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
title="Download File"
>
<Download className="w-4 h-4" />
</Button>
{fileType === "text" && (
<Button
onClick={handleSave}
size="sm"
className="gap-2"
>
<Save className="w-4 h-4" />
Save
</Button>
)}
</div>
</div>
{/* Content Area */}
<div className="flex-1 overflow-hidden relative">
{renderContent()}
</div>
</div>
)
}