Packages
phoenix_kit
1.7.52
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/pages/web/index.ex
defmodule PhoenixKit.Modules.Pages.Web.Index do
@moduledoc """
LiveView for Pages file management interface.
Provides tree-based navigation and file editing.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Pages
alias PhoenixKit.Modules.Pages.FileOperations
alias PhoenixKit.Modules.Pages.HtmlMetadata
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
def mount(_params, _session, socket) do
# Set locale
locale = socket.assigns[:current_locale] || "en"
# Check if module is enabled
if Pages.enabled?() do
# Initialize root directory
root_path = Pages.root_path()
socket =
socket
|> assign(:page_title, "Pages")
|> assign(:current_path, "/")
|> assign(:items, [])
# Root expanded by default
|> assign(:expanded_folders, MapSet.new(["/"]))
|> assign(:show_new_file_modal, false)
|> assign(:show_new_folder_modal, false)
|> assign(:new_item_name, "")
|> assign(:show_delete_modal, false)
|> assign(:delete_item_path, nil)
|> assign(:delete_item_name, nil)
|> assign(:delete_item_type, nil)
|> assign(:delete_item_counts, nil)
|> assign(:show_move_modal, false)
|> assign(:move_item_path, nil)
|> assign(:move_item_name, nil)
|> assign(:available_folders, [])
|> assign(:selected_destination, "/")
|> assign(:show_copy_modal, false)
|> assign(:copy_item_path, nil)
|> assign(:copy_item_name, nil)
|> assign(:copy_destination, "/")
|> assign(:project_title, PhoenixKit.Settings.get_project_title())
|> assign(:current_locale, locale)
|> assign(:root_path, root_path)
{:ok, socket}
else
socket =
socket
|> put_flash(:error, "Pages module is not enabled")
|> redirect(to: Routes.path("/admin/modules"))
{:ok, socket}
end
end
def handle_params(params, _uri, socket) do
# Get path from URL params, default to root
path = Map.get(params, "path", "/")
# Load directory at this path
case FileOperations.list_directory(path) do
{:ok, items} ->
# Filter out items outside sandbox
root_path = socket.assigns.root_path
filtered_items = Enum.filter(items, &item_within_sandbox?(&1, root_path))
# Enrich items with metadata for files
enriched_items = enrich_items_with_metadata(filtered_items)
socket =
socket
|> assign(:current_path, path)
|> assign(:items, enriched_items)
{:noreply, socket}
{:error, _reason} ->
# Path doesn't exist, redirect to root
socket =
socket
|> put_flash(:error, "Directory not found: #{path}")
|> push_patch(to: Routes.path("/admin/pages"))
{:noreply, socket}
end
end
## Event Handlers
def handle_event("toggle_folder", %{"path" => path}, socket) do
# Use push_patch to update URL instead of directly assigning
# handle_params will handle the actual directory loading
{:noreply, push_patch(socket, to: Routes.path("/admin/pages?path=#{path}"))}
end
def handle_event("show_new_file_modal", _params, socket) do
socket =
socket
|> assign(:show_new_file_modal, true)
|> assign(:new_item_name, "")
{:noreply, socket}
end
def handle_event("show_new_folder_modal", _params, socket) do
socket =
socket
|> assign(:show_new_folder_modal, true)
|> assign(:new_item_name, "")
{:noreply, socket}
end
def handle_event("close_new_file_modal", _params, socket) do
socket =
socket
|> assign(:show_new_file_modal, false)
|> assign(:new_item_name, "")
{:noreply, socket}
end
def handle_event("close_new_folder_modal", _params, socket) do
socket =
socket
|> assign(:show_new_folder_modal, false)
|> assign(:new_item_name, "")
{:noreply, socket}
end
def handle_event("update_new_item_name", %{"name" => name}, socket) do
socket = assign(socket, :new_item_name, name)
{:noreply, socket}
end
def handle_event("create_file", _params, socket) do
name = socket.assigns.new_item_name
current_path = socket.assigns.current_path
if name == "" do
socket = put_flash(socket, :error, "File name cannot be empty")
{:noreply, socket}
else
file_path = Path.join(current_path, name)
# Check if anything with this exact name already exists
if FileOperations.exists?(file_path) do
socket = put_flash(socket, :error, "A file or folder named '#{name}' already exists")
{:noreply, socket}
else
# Create file with default metadata
metadata = HtmlMetadata.default_metadata()
initial_content = HtmlMetadata.serialize(metadata) <> "\n\n"
case FileOperations.write_file(file_path, initial_content) do
:ok ->
socket =
socket
|> assign(:show_new_file_modal, false)
|> assign(:new_item_name, "")
|> put_flash(:info, "File created: #{name}")
|> redirect(to: Routes.path("/admin/pages/edit?path=#{file_path}"))
{:noreply, socket}
{:error, _reason} ->
socket = put_flash(socket, :error, "Failed to create file")
{:noreply, socket}
end
end
end
end
def handle_event("create_folder", _params, socket) do
name = socket.assigns.new_item_name
current_path = socket.assigns.current_path
if name == "" do
socket = put_flash(socket, :error, "Folder name cannot be empty")
{:noreply, socket}
else
folder_path = Path.join(current_path, name)
# Check if anything with this exact name already exists
if FileOperations.exists?(folder_path) do
socket = put_flash(socket, :error, "A file or folder named '#{name}' already exists")
{:noreply, socket}
else
case FileOperations.create_directory(folder_path) do
:ok ->
socket =
socket
|> assign(:show_new_folder_modal, false)
|> assign(:new_item_name, "")
|> refresh_tree()
|> put_flash(:info, "Folder created: #{name}")
{:noreply, socket}
{:error, _reason} ->
socket = put_flash(socket, :error, "Failed to create folder")
{:noreply, socket}
end
end
end
end
def handle_event("navigate_to", %{"path" => path}, socket) do
# Use push_patch to update URL instead of directly assigning
# handle_params will handle the actual directory loading
{:noreply, push_patch(socket, to: Routes.path("/admin/pages?path=#{path}"))}
end
def handle_event("show_delete_modal", %{"path" => path, "name" => name}, socket) do
# Determine if it's a file or folder and count contents if folder
item_type = if FileOperations.directory_exists?(path), do: :folder, else: :file
counts =
if item_type == :folder do
FileOperations.count_contents(path)
else
nil
end
socket =
socket
|> assign(:show_delete_modal, true)
|> assign(:delete_item_path, path)
|> assign(:delete_item_name, name)
|> assign(:delete_item_type, item_type)
|> assign(:delete_item_counts, counts)
{:noreply, socket}
end
def handle_event("close_delete_modal", _params, socket) do
socket =
socket
|> assign(:show_delete_modal, false)
|> assign(:delete_item_path, nil)
|> assign(:delete_item_name, nil)
|> assign(:delete_item_type, nil)
|> assign(:delete_item_counts, nil)
{:noreply, socket}
end
def handle_event("confirm_delete", _params, socket) do
path = socket.assigns.delete_item_path
name = socket.assigns.delete_item_name
case FileOperations.delete(path) do
{:ok, _} ->
socket =
socket
|> assign(:show_delete_modal, false)
|> assign(:delete_item_path, nil)
|> assign(:delete_item_name, nil)
|> assign(:delete_item_type, nil)
|> assign(:delete_item_counts, nil)
|> refresh_tree()
|> put_flash(:info, "Deleted: #{name}")
{:noreply, socket}
{:error, _reason} ->
socket =
socket
|> put_flash(:error, "Failed to delete: #{name}")
|> assign(:show_delete_modal, false)
|> assign(:delete_item_type, nil)
|> assign(:delete_item_counts, nil)
{:noreply, socket}
end
end
def handle_event("duplicate", %{"path" => path}, socket) do
# Generate duplicate name (always start with -1 for first duplicate)
new_path = generate_duplicate_name(path)
case FileOperations.copy(path, new_path) do
{:ok, _} ->
socket =
socket
|> refresh_tree()
|> put_flash(:info, "Duplicated: #{Path.basename(new_path)}")
{:noreply, socket}
{:error, :eisdir} ->
socket = put_flash(socket, :error, "Cannot duplicate directories")
{:noreply, socket}
{:error, _reason} ->
socket = put_flash(socket, :error, "Failed to duplicate file")
{:noreply, socket}
end
end
def handle_event("show_move_modal", %{"path" => path, "name" => name}, socket) do
# Get all available folders for destination selection
folders = list_all_folders("/")
socket =
socket
|> assign(:show_move_modal, true)
|> assign(:move_item_path, path)
|> assign(:move_item_name, name)
|> assign(:available_folders, folders)
|> assign(:selected_destination, "/")
{:noreply, socket}
end
def handle_event("close_move_modal", _params, socket) do
socket =
socket
|> assign(:show_move_modal, false)
|> assign(:move_item_path, nil)
|> assign(:move_item_name, nil)
|> assign(:available_folders, [])
{:noreply, socket}
end
def handle_event("update_destination", %{"destination" => destination}, socket) do
{:noreply, assign(socket, :selected_destination, destination)}
end
def handle_event("confirm_move", _params, socket) do
source_path = socket.assigns.move_item_path
destination_folder = socket.assigns.selected_destination
name = socket.assigns.move_item_name
# Build destination path
dest_path = Path.join(destination_folder, Path.basename(source_path))
# Check if destination already exists
if FileOperations.exists?(dest_path) do
socket =
socket
|> put_flash(:error, "A file or folder with this name already exists in the destination")
{:noreply, socket}
else
case FileOperations.move(source_path, dest_path) do
:ok ->
socket =
socket
|> assign(:show_move_modal, false)
|> assign(:move_item_path, nil)
|> assign(:move_item_name, nil)
|> refresh_tree()
|> put_flash(:info, "Moved #{name} to #{destination_folder}")
{:noreply, socket}
{:error, _reason} ->
socket =
socket
|> put_flash(:error, "Failed to move #{name}")
|> assign(:show_move_modal, false)
{:noreply, socket}
end
end
end
def handle_event("show_copy_modal", %{"path" => path, "name" => name}, socket) do
# Get all available folders for destination selection
folders = list_all_folders("/")
socket =
socket
|> assign(:show_copy_modal, true)
|> assign(:copy_item_path, path)
|> assign(:copy_item_name, name)
|> assign(:available_folders, folders)
|> assign(:copy_destination, "/")
{:noreply, socket}
end
def handle_event("close_copy_modal", _params, socket) do
socket =
socket
|> assign(:show_copy_modal, false)
|> assign(:copy_item_path, nil)
|> assign(:copy_item_name, nil)
|> assign(:available_folders, [])
{:noreply, socket}
end
def handle_event("update_copy_destination", %{"destination" => destination}, socket) do
{:noreply, assign(socket, :copy_destination, destination)}
end
def handle_event("confirm_copy", _params, socket) do
source_path = socket.assigns.copy_item_path
destination_folder = socket.assigns.copy_destination
name = socket.assigns.copy_item_name
# Build destination path
dest_path = Path.join(destination_folder, Path.basename(source_path))
# Check if destination already exists, if so, generate unique name
final_dest_path =
if FileOperations.exists?(dest_path) do
FileOperations.generate_unique_name(dest_path)
else
dest_path
end
case FileOperations.copy(source_path, final_dest_path) do
{:ok, _} ->
socket =
socket
|> assign(:show_copy_modal, false)
|> assign(:copy_item_path, nil)
|> assign(:copy_item_name, nil)
|> refresh_tree()
|> put_flash(:info, "Copied #{name} to #{destination_folder}")
{:noreply, socket}
{:error, :eisdir} ->
socket =
socket
|> put_flash(:error, "Cannot copy directories")
|> assign(:show_copy_modal, false)
{:noreply, socket}
{:error, _reason} ->
socket =
socket
|> put_flash(:error, "Failed to copy #{name}")
|> assign(:show_copy_modal, false)
{:noreply, socket}
end
end
def handle_event("change_file_status", %{"path" => path, "status" => new_status}, socket) do
case FileOperations.read_file(path) do
{:ok, content} ->
updated_content = update_metadata_status(content, new_status)
case FileOperations.write_file(path, updated_content) do
:ok ->
socket =
socket
|> refresh_tree()
|> put_flash(:info, "Status changed to: #{new_status}")
{:noreply, socket}
{:error, _reason} ->
socket = put_flash(socket, :error, "Failed to update status")
{:noreply, socket}
end
{:error, _reason} ->
socket = put_flash(socket, :error, "Failed to read file")
{:noreply, socket}
end
end
def handle_event("debug_edit_click", %{"path" => path}, socket) do
# Navigate to the editor
{:noreply,
push_navigate(socket, to: Routes.path("/admin/pages/edit?path=#{URI.encode(path)}"))}
end
## Private Helpers
defp refresh_tree(socket) do
case FileOperations.list_directory(socket.assigns.current_path) do
{:ok, items} ->
enriched_items = enrich_items_with_metadata(items)
assign(socket, :items, enriched_items)
{:error, _reason} ->
socket
end
end
defp enrich_items_with_metadata(items) do
Enum.map(items, &enrich_single_item/1)
end
defp enrich_single_item(%{type: :file} = item) do
file_info = get_file_info(item.path)
metadata = get_file_metadata(item.path)
item
|> Map.put(:metadata, metadata)
|> Map.put(:size, file_info.size)
|> Map.put(:mtime, file_info.mtime)
end
defp enrich_single_item(item), do: item
defp get_file_info(path) do
case FileOperations.file_info(path) do
{:ok, info} -> info
{:error, _} -> %{size: 0, mtime: nil}
end
end
defp get_file_metadata(path) do
with {:ok, content} <- FileOperations.read_file(path),
{:ok, meta, _stripped} <- HtmlMetadata.parse(content) do
meta
else
_ -> HtmlMetadata.default_metadata()
end
end
defp list_all_folders(path) do
root_path = Pages.root_path()
case FileOperations.list_directory(path) do
{:ok, items} ->
folders =
items
|> Enum.filter(&(&1.type == :folder and folder_within_sandbox?(&1, root_path)))
|> Enum.flat_map(fn folder ->
# Add current folder and all its subfolders
[folder.path | list_all_folders(folder.path)]
end)
# Always include root
["/" | folders]
|> Enum.uniq()
|> Enum.sort()
{:error, _reason} ->
["/"]
end
end
defp item_within_sandbox?(item, root_path) do
# Build the full path to verify it's within sandbox
full_path = Path.join(root_path, String.trim_leading(item.path, "/"))
# Verify the item actually exists and is what it claims to be
case item.type do
:folder ->
# Verify it's actually a directory
if File.dir?(full_path) do
check_path_within_sandbox(full_path, root_path)
else
false
end
:file ->
# Verify it's actually a file
if File.exists?(full_path) and not File.dir?(full_path) do
check_path_within_sandbox(full_path, root_path)
else
false
end
end
end
defp folder_within_sandbox?(item, root_path) do
# Build the full path to verify it's within sandbox
full_path = Path.join(root_path, String.trim_leading(item.path, "/"))
# First, verify it's actually a directory (not a file misidentified as folder)
if File.dir?(full_path) do
check_path_within_sandbox(full_path, root_path)
else
false
end
end
defp check_path_within_sandbox(full_path, root_path) do
# Resolve to absolute path (follows symlinks)
case File.read_link(full_path) do
{:ok, link_target} ->
# It's a symlink - check if target is within sandbox
resolved_path =
if String.starts_with?(link_target, "/") do
link_target
else
Path.expand(link_target, Path.dirname(full_path))
end
String.starts_with?(resolved_path, root_path)
{:error, _} ->
# Not a symlink, verify the path itself is within root
String.starts_with?(Path.expand(full_path), root_path)
end
end
defp generate_duplicate_name(path) do
# Extract base and extension
{base, ext} =
if Path.extname(path) != "" do
{Path.rootname(path), Path.extname(path)}
else
{path, ""}
end
# Start checking from -1
do_generate_duplicate_name(base, ext, 1)
end
defp do_generate_duplicate_name(base, ext, counter) do
new_path = "#{base}-#{counter}#{ext}"
if FileOperations.exists?(new_path) do
do_generate_duplicate_name(base, ext, counter + 1)
else
new_path
end
end
defp update_metadata_status(content, new_status) do
case HtmlMetadata.parse(content) do
{:ok, metadata, _stripped_content} ->
# Update both status and updated_at timestamp
updated_metadata =
metadata
|> Map.put(:status, new_status)
|> Map.put(:updated_at, UtilsDate.utc_now())
HtmlMetadata.update_metadata(content, updated_metadata)
{:error, :no_metadata} ->
# No metadata exists, create new metadata with the status
metadata =
HtmlMetadata.default_metadata()
|> Map.put(:status, new_status)
|> Map.put(:updated_at, UtilsDate.utc_now())
# Prepend metadata to content
HtmlMetadata.serialize(metadata) <> "\n\n" <> content
end
end
end