defmodule Omni.UI.FilesUI do
@moduledoc """
Function components for the files panel.
Used internally by `Omni.UI.FilesComponent` to render the file list,
file viewer, and panel header. Not imported by `use Omni.UI`.
"""
use Phoenix.Component
alias Omni.Tools.Files.Entry
alias Phoenix.LiveView.JS
import Omni.UI.CoreUI, only: [panel_header: 1]
@doc """
Header bar for the files panel.
Shows a back arrow when viewing a file, and slots in the source toggle,
download link, and close button on the right.
"""
attr :file, Entry, default: nil
attr :view_source, :boolean, default: false
attr :token, :string, required: true
attr :target, :any, default: nil
def files_panel_header(assigns) do
~H"""
<.panel_header title="All files" align="left">
<:left>
<%= if @file do %>
<button
class={[
"flex items-center justify-center size-8 rounded cursor-pointer",
"text-omni-text-1 hover:text-omni-accent-1 hover:bg-omni-accent-2/10"
]}
phx-click="close"
phx-target={@target}>
<Lucideicons.arrow_left class="size-4" />
</button>
<% else %>
<div class="flex items-center justify-center size-8">
<Lucideicons.list class="size-4" />
</div>
<% end %>
</:left>
<:right>
<.source_toggle
:if={toggleable?(@file)}
target={@target}
view_source={@view_source} />
<a
:if={@file}
class={[
"flex items-center justify-center size-8 rounded transition-colors cursor-pointer",
"text-omni-text-1 hover:text-omni-accent-1 hover:bg-omni-accent-2/10"
]}
href={file_url(@token, @file.filename)}
download={@file.filename}>
<Lucideicons.download class="size-4" />
</a>
<button
class={[
"flex items-center justify-center size-8 rounded cursor-pointer",
"text-omni-text-1 hover:text-omni-accent-1 hover:bg-omni-accent-2/10"
]}
title="Close files"
phx-click={JS.push("toggle", value: %{name: "files"})}>
<Lucideicons.x class="size-4" />
</button>
</:right>
</.panel_header>
"""
end
@doc "Renders the directory listing with name, size, and updated columns."
attr :files, :map, required: true
attr :error, :string, default: nil
attr :target, :any, default: nil
def file_list(assigns) do
~H"""
<div class="size-full p-6 flex flex-col overflow-y-auto">
<div :if={@error} class="flex items-center gap-3 mb-4 px-4 py-3 text-red-600 bg-omni-bg-2 border border-red-500 rounded">
<Lucideicons.triangle_alert class="size-4" />
<p class="text-sm">{@error}</p>
</div>
<%= if @files == %{} do %>
<div class="flex-1 flex items-center justify-center">
<p class="text-sm text-omni-text-3 italic">No files yet.</p>
</div>
<% else %>
<div class="border-b border-omni-border-2">
<div class="px-2 py-3 grid grid-cols-[50%_1fr_1fr] gap-x-4 text-sm font-medium text-omni-text">
<div>Name</div>
<div>Size</div>
<div>Updated</div>
</div>
</div>
<div
:for={{filename, file} <- Enum.sort(@files)}
class="border-b border-omni-border-3">
<div
class={[
"px-2 py-3 grid grid-cols-[50%_1fr_1fr] gap-x-4 text-sm text-omni-text-3 group cursor-pointer transition-colors",
"hover:bg-omni-bg-2"
]}
phx-click="open"
phx-value-filename={filename}
phx-target={@target}>
<div class={[
"flex items-center gap-2 transition-colors",
"text-omni-text-1 group-hover:text-omni-accent-1"
]}>
<Lucideicons.file_code class="size-4" />
<span class="font-medium">{filename}</span>
</div>
<div>{format_bytes(file.size)}</div>
<div>{Calendar.strftime(file.mtime, "%d %b %Y, %I:%M%P")}</div>
</div>
</div>
<% end %>
</div>
"""
end
@doc """
Renders file content, dispatching on the `:view` assign.
View modes: `:iframe` (HTML, PDF), `:markdown` (rendered Markdown),
`:source` (syntax-highlighted text), `:media` (images), `:download`
(fallback download link).
"""
attr :file, Entry, required: true
attr :content, :any, default: nil, doc: "pre-rendered content for :markdown and :source views"
attr :view, :atom, required: true, doc: "one of :iframe, :markdown, :source, :media, :download"
attr :token, :string, required: true
attr :target, :any, default: nil
def file_view(%{view: :iframe} = assigns) do
~H"""
<iframe
src={file_url(@token, @file.filename)}
sandbox={if(@file.media_type == "text/html", do: "allow-scripts")}
class="size-full border-0" />
"""
end
def file_view(%{view: :markdown} = assigns) do
~H"""
<div class="min-h-full p-6">
<div class="max-w-xl mx-auto mdex leading-[1.5]">
{@content}
</div>
</div>
"""
end
def file_view(%{view: :source} = assigns) do
~H"""
<div
class={[
"h-full",
"[&>pre]:min-h-full! [&>pre]:m-0 [&>pre]:p-6 [&>pre]:text-sm",
"[&>pre]:whitespace-pre-wrap"
]}>
{@content}
</div>
"""
end
def file_view(%{view: :media} = assigns) do
~H"""
<div class="min-h-full flex items-center justify-center p-6 bg-omni-bg-1">
<img
src={file_url(@token, @file.filename)}
alt={@file.filename}
class="max-w-full h-auto border border-omni-border-2"
/>
</div>
"""
end
def file_view(%{view: :download} = assigns) do
~H"""
<div class="h-full flex items-center justify-center p-6">
<a
href={file_url(@token, @file.filename)}
download={@file.filename}
class={[
"inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border transition-colors cursor-pointer",
"text-omni-text-1 border-omni-border-3 hover:text-omni-accent-1 hover:bg-omni-accent-2/5 hover:border-omni-accent-2"
]}>
<Lucideicons.download class="size-4" />
<span class="font-medium">Download</span>
</a>
</div>
"""
end
@doc "Preview/Code toggle for file types that support both views."
attr :view_source, :boolean, default: false
attr :target, :any, default: nil
def source_toggle(assigns) do
~H"""
<div
class="flex items-center rounded-lg bg-omni-bg-1 p-0.5 text-xs font-medium">
<button
phx-click="toggle" phx-target={@target}
class={[
"px-2.5 py-1 rounded-md transition-colors cursor-pointer",
if(@view_source == false,
do: "bg-omni-bg text-omni-text shadow-sm",
else: "text-omni-text-3 hover:text-omni-text-1")
]}>
Preview
</button>
<button
phx-click="toggle" phx-target={@target}
class={[
"px-2.5 py-1 rounded-md transition-colors cursor-pointer",
if(@view_source == true,
do: "bg-omni-bg text-omni-text shadow-sm",
else: "text-omni-text-3 hover:text-omni-text-1")
]}>
Code
</button>
</div>
"""
end
# ---- HELPERS
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1024, 1)} KB"
defp toggleable?(%Entry{media_type: mime_type})
when mime_type in ["text/html", "text/markdown", "image/svg+xml"],
do: true
defp toggleable?(_), do: false
defp file_url(token, filename) do
"#{url_prefix()}/#{token}/#{URI.encode(filename)}"
end
defp url_prefix do
Application.get_env(:omni_ui, :files_url_prefix, "/omni_files")
end
end