Skip to content

Instantly share code, notes, and snippets.

@nileshtrivedi
Created December 9, 2024 12:17
Show Gist options
  • Save nileshtrivedi/fe35f9ffee98121583dd1fd102a817dd to your computer and use it in GitHub Desktop.
Save nileshtrivedi/fe35f9ffee98121583dd1fd102a817dd to your computer and use it in GitHub Desktop.
Attempt to make a single-file multi-user liveview-based ToDo application
# postgres should be running
# it should have a table called todos
# See Migrations module on how to create the table
# The application can be started with
# JWT_SECRET=6fa91f11ca785e9a73d340ea8d3d03ed SECRET_KEY_BASE=6fa91f11ca785e9a73d340ea8d3d03ed6fa91f11ca785e9a73d340ea8d3d03ed elixir app.exs
# To access the app with authentication, visit this URL that includes the JWT token:
# http://localhost:4000/?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.keYoDT2sQMzTicz_1oqmfduw0HYrwnxc4iwoq9Vr-hQ
# Current issues:
# LiveView's second mount() does not include session data
# phoenix_playground does not support on_mount callback: https://github.com/phoenix-playground/phoenix_playground/issues/9
# Trying to use a custom endpoint suppresses errors because: (ArgumentError) no "500" html template defined for Demo.ErrorView
Mix.install([
{:phoenix_playground, "== 0.1.6"},
{:phoenix_ecto, "== 4.6.3"},
{:ecto_sql, "== 3.12.1"},
{:postgrex, "== 0.19.3"},
{:jason, "~> 1.4"},
{:jose, "~> 1.11"}
])
defmodule MyRepo do
use Ecto.Repo, otp_app: :myapp, adapter: Ecto.Adapters.Postgres
end
defmodule Todo do
use Ecto.Schema
schema "todos" do
field :name, :string
field :status, :string, default: "pending"
field :user_id, :string
timestamps()
end
def changeset(todo, attrs \\ %{}) do
todo
|> Ecto.Changeset.cast(attrs, [:name, :status, :user_id])
|> Ecto.Changeset.validate_required([:name, :user_id])
|> Ecto.Changeset.validate_format(:user_id, ~r/^[\w-]+$/, message: "Invalid user ID")
end
end
Application.put_env(:myapp, MyRepo,
database: "postgres",
username: "postgres",
password: "",
hostname: "localhost",
port: 5432
)
defmodule HomeLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
user_id = "dummyuser"
count = MyRepo.aggregate("todos", :count)
{:ok, assign(socket, count: count, user_id: user_id)}
end
def render(assigns) do
~H"""
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<div class="max-w-md mx-auto mt-10 p-6 bg-gray-200 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">Welcome to the Todo App</h1>
<p>We have {@count} todos. You are {@user_id}</p>
<p class="text-gray-700">Please visit the <a href="/app" class="text-blue-500 underline hover:text-blue-600">app</a> to continue</p>
</div>
"""
end
end
defmodule TodoLive do
use Phoenix.LiveView
import Ecto.Query
def get_user_todos(user_id) do
MyRepo.all(from t in Todo, where: t.user_id == ^user_id)
end
def mount(_params, _session, socket) do
user_id = "foobar"
tasks = get_user_todos(user_id)
{:ok, assign(socket, todos: tasks, user_id: user_id, new_todo_name: "drink water")}
end
def handle_event("create_todo", %{"name" => name}, socket) do
# Trim and validate todo name
name = String.trim(name)
if name == "" do
{:noreply, socket}
else
# Create new todo with current user's ID
{:ok, _todo} =
%Todo{}
|> Todo.changeset(%{
name: name,
user_id: socket.assigns.user_id
})
|> MyRepo.insert()
# Update socket with new list of todos
socket =
socket
|> assign(:todos, get_user_todos(socket.assigns.user_id))
|> assign(:new_todo_name, "")
{:noreply, socket}
end
end
def handle_event("toggle_todo", %{"id" => id}, socket) do
# Find the todo and ensure it belongs to current user
todo = MyRepo.get_by!(Todo, [id: id, user_id: socket.assigns.user_id])
# Toggle status
new_status = if todo.status == "pending", do: "done", else: "pending"
# Update todo
{:ok, _todo} =
todo
|> Todo.changeset(%{status: new_status})
|> MyRepo.update()
# Refresh todos
{:noreply, assign(socket, :todos, get_user_todos(socket.assigns.user_id))}
end
def handle_event("delete_todo", %{"id" => id}, socket) do
# Delete todo, ensuring it belongs to current user
todo = MyRepo.get_by!(Todo, [id: id, user_id: socket.assigns.user_id])
MyRepo.delete(todo)
# Refresh todos
{:noreply, assign(socket, :todos, get_user_todos(socket.assigns.user_id))}
end
def render(assigns) do
~H"""
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<div class="max-w-md mx-auto mt-10 p-6 bg-gray-200 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">Todo List for <%= @user_id %></h1>
<form phx-submit="create_todo" class="mb-4 flex">
<input
type="text"
name="name"
value={@new_todo_name}
placeholder="Enter a new todo"
class="flex-grow mr-2 px-2 py-1 border rounded"
/>
<button
type="submit"
class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600"
>
Add Todo
</button>
</form>
<ul>
<%= for todo <- @todos do %>
<li class="flex items-center justify-between py-2 border-b">
<label class="flex items-center gap-2">
<input
type="checkbox"
phx-click="toggle_todo"
phx-value-id={todo.id}
checked={todo.status == "done"}
class="mr-2"
/>
<span class={if todo.status == "done", do: "line-through text-gray-500", else: ""}>
<%= todo.name %>
</span>
<span>by <%= todo.user_id %></span>
</label>
<button
phx-click="delete_todo"
phx-value-id={todo.id}
class="text-red-500 hover:text-red-700"
>
</button>
</li>
<% end %>
</ul>
<p class="text-gray-700"><a href="/" class="text-blue-500 underline hover:text-blue-600">homepage</a></p>
</div>
"""
end
end
{:ok, _} = Application.ensure_all_started(:postgrex)
{:ok, _} = Application.ensure_all_started(:ecto)
defmodule AuthPlug do
import Plug.Conn
defp verify_token(jwt) do
jwk = %{
"kty" => "oct",
"k" => :jose_base64url.encode(System.get_env("JWT_SECRET"))
}
case JOSE.JWT.verify_strict(jwk, ["HS256"], jwt) do
{true, claims, _} -> {:ok, claims.fields["sub"]}
_ -> :error
end
end
def init(opts), do: opts
def call(conn, _opts) do
if user_id = get_session(conn, :user_id) do
IO.inspect "Found user_id=#{user_id} in session"
# assign(conn, :user_id, user_id)
conn
else
IO.inspect "Checking JWT"
jwt = conn.query_params["jwt"]
case verify_token(jwt) do
{:ok, sub} ->
IO.inspect "Verified sub=#{sub}"
conn
|> put_session(:user_id, sub)
|> put_session(:jwt, jwt)
:error ->
conn
|> put_resp_content_type("text/plain")
|> send_resp(401, "Unauthorized")
|> halt()
end
end
end
end
defmodule Demo.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :put_secret_key_base
plug :fetch_session
plug :put_root_layout, html: {PhoenixPlayground.Layout, :root}
plug :put_secure_browser_headers
plug AuthPlug
end
scope "/" do
pipe_through :browser
live "/", HomeLive
live "/app", TodoLive
end
def put_secret_key_base(conn, _) do
put_in conn.secret_key_base, System.get_env("SECRET_KEY_BASE")
end
end
defmodule Demo.Endpoint do
use Phoenix.Endpoint, otp_app: :phoenix_playground
plug Plug.Logger
socket "/live", Phoenix.LiveView.Socket
plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix"
plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view"
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader, reloader: &PhoenixPlayground.CodeReloader.reload/2
plug Demo.Router
end
# Create todos table if not exists
defmodule Migrations do
def migrate do
create_todos_table()
end
defp create_todos_table do
MyRepo.query!("""
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
inserted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
end
end
# Run migrations
# MyRepo.start_link()
# Migrations.migrate()
PhoenixPlayground.start(
open_browser: false,
live_reload: true,
child_specs: [
{MyRepo, []}
],
# endpoint: Demo.Endpoint,
# endpoint_options: [debug_errors: true]
plug: Demo.Router
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment