defmodule Raxol.Examples.TodoApp do
@moduledoc """
A fully-featured todo list application demonstrating Raxol's capabilities.
Features:
- Add, edit, and delete todos
- Mark todos as complete
- Filter by status (all, active, completed)
- Persist todos to disk
- Keyboard shortcuts
- Search functionality
"""
use Raxol.Core.Runtime.Application
import Raxol.LiveView, only: [assign: 2, assign: 3, update: 3]
@storage_file Path.expand("~/.raxol_todos.json")
@impl true
def mount(_params, socket) do
todos = load_todos()
{:ok,
socket
|> assign(todos: todos)
|> assign(filter: :all)
|> assign(input: "")
|> assign(search: "")
|> assign(editing_id: nil)
|> assign(edit_text: "")
|> register_shortcuts([
{"ctrl+n", "new_todo"},
{"ctrl+d", "clear_completed"},
{"ctrl+s", "save_todos"},
{"ctrl+f", "focus_search"},
{"/", "focus_search"},
{"escape", "cancel_edit"}
])}
end
@impl true
def render(assigns) do
filtered_todos = filter_todos(assigns.todos, assigns.filter, assigns.search)
stats = calculate_stats(assigns.todos)
~H"""
📝 Todo List
<%= stats.active %> active, <%= stats.completed %> completed
<%= if length(filtered_todos) == 0 do %>
<%= empty_message(@filter, @search) %>
<% else %>
<%= for todo <- filtered_todos do %>
<% end %>
<% end %>
Ctrl+S to save | Ctrl+D to clear completed
"""
end
# Event Handlers
@impl true
def handle_event("update_input", %{"value" => value}, socket) do
{:noreply, assign(socket, input: value)}
end
@impl true
def handle_event("add_todo", _, socket) do
text = String.trim(socket.assigns.input)
if text != "" do
todo = %{
id: generate_id(),
text: text,
completed: false,
created_at: DateTime.utc_now(),
updated_at: DateTime.utc_now()
}
socket =
socket
|> update(:todos, &[todo | &1])
|> assign(input: "")
|> save_todos_to_disk()
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def handle_event("toggle_todo", %{"id" => id}, socket) do
todos =
Enum.map(socket.assigns.todos, fn todo ->
if todo.id == id do
%{todo | completed: !todo.completed, updated_at: DateTime.utc_now()}
else
todo
end
end)
socket =
socket
|> assign(todos: todos)
|> save_todos_to_disk()
{:noreply, socket}
end
@impl true
def handle_event("start_edit", %{"id" => id}, socket) do
todo = Enum.find(socket.assigns.todos, &(&1.id == id))
socket =
socket
|> assign(editing_id: id)
|> assign(edit_text: todo.text)
{:noreply, socket}
end
@impl true
def handle_event("update_edit", %{"value" => value}, socket) do
{:noreply, assign(socket, edit_text: value)}
end
@impl true
def handle_event("save_edit", _, socket) do
text = String.trim(socket.assigns.edit_text)
if text != "" do
todos =
Enum.map(socket.assigns.todos, fn todo ->
if todo.id == socket.assigns.editing_id do
%{todo | text: text, updated_at: DateTime.utc_now()}
else
todo
end
end)
socket =
socket
|> assign(todos: todos)
|> assign(editing_id: nil)
|> assign(edit_text: "")
|> save_todos_to_disk()
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def handle_event("cancel_edit", _, socket) do
socket =
socket
|> assign(editing_id: nil)
|> assign(edit_text: "")
{:noreply, socket}
end
@impl true
def handle_event("delete_todo", %{"id" => id}, socket) do
todos = Enum.reject(socket.assigns.todos, &(&1.id == id))
socket =
socket
|> assign(todos: todos)
|> save_todos_to_disk()
{:noreply, socket}
end
@impl true
def handle_event("change_filter", %{"value" => filter}, socket) do
{:noreply, assign(socket, filter: String.to_atom(filter))}
end
@impl true
def handle_event("update_search", %{"value" => value}, socket) do
{:noreply, assign(socket, search: value)}
end
@impl true
def handle_event("mark_all_complete", _, socket) do
todos =
Enum.map(socket.assigns.todos, fn todo ->
%{todo | completed: true, updated_at: DateTime.utc_now()}
end)
socket =
socket
|> assign(todos: todos)
|> save_todos_to_disk()
{:noreply, socket}
end
@impl true
def handle_event("clear_completed", _, socket) do
todos = Enum.reject(socket.assigns.todos, & &1.completed)
socket =
socket
|> assign(todos: todos)
|> save_todos_to_disk()
{:noreply, socket}
end
@impl true
def handle_event("new_todo", _, socket) do
# Focus the input field (implementation depends on focus management)
{:noreply, socket}
end
@impl true
def handle_event("save_todos", _, socket) do
socket = save_todos_to_disk(socket)
Raxol.Toast.show("Todos saved!", type: :success)
{:noreply, socket}
end
@impl true
def handle_event("focus_search", _, socket) do
# Focus the search field (implementation depends on focus management)
{:noreply, socket}
end
# Helper Functions
defp filter_todos(todos, filter, search) do
todos
|> filter_by_status(filter)
|> filter_by_search(search)
|> Enum.sort_by(& &1.created_at, {:desc, DateTime})
end
defp filter_by_status(todos, :all), do: todos
defp filter_by_status(todos, :active), do: Enum.reject(todos, & &1.completed)
defp filter_by_status(todos, :completed),
do: Enum.filter(todos, & &1.completed)
defp filter_by_search(todos, ""), do: todos
defp filter_by_search(todos, search) do
search_term = String.downcase(search)
Enum.filter(todos, fn todo ->
String.contains?(String.downcase(todo.text), search_term)
end)
end
defp calculate_stats(todos) do
completed = Enum.count(todos, & &1.completed)
%{
total: length(todos),
active: length(todos) - completed,
completed: completed
}
end
defp empty_message(:all, ""), do: "No todos yet. Add one above!"
defp empty_message(:all, _search), do: "No todos match your search."
defp empty_message(:active, _), do: "No active todos. Nice work!"
defp empty_message(:completed, _), do: "No completed todos yet."
defp generate_id do
:crypto.strong_rand_bytes(16) |> Base.encode16()
end
defp save_todos_to_disk(socket) do
todos_json = Jason.encode!(socket.assigns.todos, pretty: true)
File.write!(@storage_file, todos_json)
socket
end
defp load_todos do
if File.exists?(@storage_file) do
@storage_file
|> File.read!()
|> Jason.decode!()
|> Enum.map(fn todo ->
%{
"id" => id,
"text" => text,
"completed" => completed,
"created_at" => created_at,
"updated_at" => updated_at
} = todo
%{
id: id,
text: text,
completed: completed,
created_at: parse_datetime(created_at),
updated_at: parse_datetime(updated_at)
}
end)
else
[]
end
rescue
_ -> []
end
defp parse_datetime(nil), do: DateTime.utc_now()
defp parse_datetime(string) when is_binary(string) do
case DateTime.from_iso8601(string) do
{:ok, datetime, _} -> datetime
_ -> DateTime.utc_now()
end
end
end
# TodoItem Component
defmodule Raxol.Examples.TodoApp.TodoItem do
use Raxol.Component
prop(:todo, :map, required: true)
prop(:editing, :boolean, default: false)
prop(:editText, :string, default: "")
prop(:onToggle, :string)
prop(:onEdit, :string)
prop(:onUpdateEdit, :string)
prop(:onSaveEdit, :string)
prop(:onCancelEdit, :string)
prop(:onDelete, :string)
@impl true
def render(assigns) do
~H"""
<%= if @editing do %>
<% else %>
<%= @todo.text %>
<% end %>
<%= unless @editing do %>
<% end %>
"""
end
end