Eidos

Installation Notice

The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.

Universal Viewer

By: Mayne

Install Latest (v0.0.1)

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>
  )
}

Information

Author
Mayne
Type
block/fileHandler
Latest Version
0.0.1
Last Updated
11/19/2025
Published
11/19/2025

Version History

  • v0.0.1 11/19/2025