Last active
January 30, 2026 14:17
-
-
Save Wigny/1511be15356edbd996389d9988fc44c0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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