Skip to content

Instantly share code, notes, and snippets.

@Wigny
Last active January 30, 2026 14:17
Show Gist options
  • Select an option

  • Save Wigny/1511be15356edbd996389d9988fc44c0 to your computer and use it in GitHub Desktop.

Select an option

Save Wigny/1511be15356edbd996389d9988fc44c0 to your computer and use it in GitHub Desktop.
Application.put_env(:example, Example.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 5001],
server: true,
adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: Example.ErrorHTML], layout: false],
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64)
)
Application.put_env(:phoenix, :json_library, JSON)
Mix.install([
{:bandit, "~> 1.10"},
{:phoenix, "~> 1.8"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.7"},
{:ecto, "~> 3.13"},
{:daisy_ui_components, "~> 0.9"}
])
defmodule Example.Duration do
use Ecto.Type
def type, do: :string
def cast(value) when is_binary(value) do
case Duration.from_iso8601(value) do
{:ok, duration} -> cast(duration)
{:error, _reason} -> :error
end
end
def cast(%Duration{} = duration) do
{:ok, duration}
end
def cast(_value) do
:error
end
def load(_value), do: raise("unused")
def dump(_value), do: raise("unused")
end
defmodule Example.Movie do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :name, :string
field :duration, Example.Duration
end
def changeset(movie, attrs \\ %{}) do
movie
|> cast(attrs, [:name, :duration])
|> validate_required([:name, :duration])
end
end
defmodule Example.ErrorHTML do
def render(template, _assigns), do: Phoenix.Controller.status_message_from_template(template)
end
defmodule Example.Layouts do
use Phoenix.Component
def render("root.html", assigns) do
~H"""
<!DOCTYPE html>
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script src={"https://unpkg.com/phoenix@#{Application.spec(:phoenix, :vsn)}/priv/static/phoenix.min.js"}>
</script>
<script src={"https://unpkg.com/phoenix_live_view@#{Application.spec(:phoenix_live_view, :vsn)}/priv/static/phoenix_live_view.min.js"}>
</script>
<script type="module">
const DATE_UNITS = [
{ iso: "Y", display: "a" },
{ iso: "M", display: "mo" },
{ iso: "W", display: "wk" },
{ iso: "D", display: "d" },
];
const TIME_UNITS = [
{ iso: "H", display: "h" },
{ iso: "M", display: "min" },
{ iso: "S", display: "s" },
];
const ALL_UNITS = [...DATE_UNITS, ...TIME_UNITS];
customElements.define("duration-input", class extends HTMLElement {
static formAssociated = true;
#internals;
#input;
#handleInput = () => this.#syncFormValue();
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
const input = this.querySelector("input");
if (!input) throw new Error("duration-input requires an <input> child");
this.#input = input;
this.#input.pattern = String.raw`^(?!.*(a|mo|wk|d|h|min|s).*\1)((\d+(a|mo|wk|d|h|min)|\d+(\.\d+)?s)\s*)+$`;
this.#input.title = "Format: 1a 2mo 3wk 4d 5h 6min 7.89s";
this.#input.value = this.#toDisplay(this.#input.value);
this.#syncFormValue();
this.#input.addEventListener("input", this.#handleInput);
}
disconnectedCallback() {
this.#input.removeEventListener("input", this.#handleInput);
}
formResetCallback() {
this.#input.value = this.#toDisplay(this.#input.defaultValue);
this.#syncFormValue();
}
formStateRestoreCallback(state) {
this.#input.value = this.#toDisplay(state);
this.#syncFormValue();
}
formDisabledCallback(disabled) {
this.#input.disabled = disabled;
}
#syncFormValue() {
const value = this.#input.value.trim();
const formValue = value && this.#input.validity.valid ? this.#toISO(value) : value;
this.#internals.setFormValue(formValue);
}
#toDisplay(iso) {
const match = iso.match(
/^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/
);
if (!match) return iso;
const [, ...values] = match;
return ALL_UNITS
.map(({ display }, i) => values[i] && `${values[i]}${display}`)
.filter(Boolean)
.join(" ");
}
#toISO(display) {
const values = new Map();
for (const token of display.trim().split(/\s+/)) {
const [, num, unit] = token.match(/^(\d+(?:\.\d+)?)(a|mo|wk|d|h|min|s)$/) || [];
if (num) values.set(unit, num);
}
const date = DATE_UNITS
.map(({ iso, display }) => values.get(display) && values.get(display) + iso)
.filter(Boolean)
.join("");
const time = TIME_UNITS
.map(({ iso, display }) => values.get(display) && values.get(display) + iso)
.filter(Boolean)
.join("");
return "P" + date + (time ? "T" + time : "");
}
});
let liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket)
liveSocket.connect()
</script>
</head>
<body>{@inner_content}</body>
</html>
"""
end
def render("app.html", assigns) do
~H"""
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{@inner_content}
</div>
</main>
"""
end
end
defmodule Example.MovieLive do
use Phoenix.LiveView, layout: {Example.Layouts, :app}
use DaisyUIComponents
def render(assigns) do
~H"""
<.form id="movie-form" for={@form} phx-change="validate" phx-submit="save">
<.input type="text" field={@form[:name]} label="Name" />
<.fieldset class="mt-2">
<.fieldset_label for={@form[:duration].id}>Duration</.fieldset_label>
<duration-input
name={@form[:duration].name}
phx-update="ignore"
id="skip_recap_start_time_input"
>
<.input
type="text"
id={@form[:duration].id}
class="w-full"
value={@form[:duration].value}
/>
</duration-input>
<.error field={@form[:duration]} />
</.fieldset>
<.button type="submit" color="primary" class="mt-2">Save</.button>
</.form>
<p>Movie name: {@movie.name}</p>
<p>Movie duration: {@movie.duration}</p>
"""
end
def mount(_params, _session, socket) do
movie = %Example.Movie{name: "Matrix", duration: Duration.new!(minute: 136)}
changeset = Example.Movie.changeset(movie)
{:ok,
socket
|> assign(:movie, movie)
|> assign(:form, to_form(changeset))}
end
def handle_event("validate", %{"movie" => params}, socket) do
changeset = Example.Movie.changeset(socket.assigns.movie, params)
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
def handle_event("save", %{"movie" => params}, socket) do
changeset = Example.Movie.changeset(socket.assigns.movie, params)
case Ecto.Changeset.apply_action(changeset, :insert) do
{:ok, movie} ->
changeset = Example.Movie.changeset(movie)
{:noreply,
socket
|> assign(:movie, movie)
|> assign(:form, to_form(changeset))}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, action: :save))}
end
end
end
defmodule Example.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :put_root_layout, {Example.Layouts, :root}
end
scope "/", Example do
pipe_through :browser
live "/", MovieLive, :index
end
end
defmodule Example.Endpoint do
use Phoenix.Endpoint, otp_app: :example
socket "/live", Phoenix.LiveView.Socket
plug Example.Router
end
{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment