🔥 initial commit 🔥
This commit is contained in:
23
.formatter.exs
Normal file
23
.formatter.exs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[
|
||||||
|
import_deps: [
|
||||||
|
:ash_typescript,
|
||||||
|
:ash_ai,
|
||||||
|
:ash_state_machine,
|
||||||
|
:ash_admin,
|
||||||
|
:ash_authentication_phoenix,
|
||||||
|
:ash_authentication,
|
||||||
|
:ash_postgres,
|
||||||
|
:ash_json_api,
|
||||||
|
:ash_graphql,
|
||||||
|
:absinthe,
|
||||||
|
:ash_phoenix,
|
||||||
|
:ash,
|
||||||
|
:reactor,
|
||||||
|
:ecto,
|
||||||
|
:ecto_sql,
|
||||||
|
:phoenix
|
||||||
|
],
|
||||||
|
subdirectories: ["priv/*/migrations"],
|
||||||
|
plugins: [Absinthe.Formatter, Spark.Formatter, Phoenix.LiveView.HTMLFormatter],
|
||||||
|
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||||
|
]
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
|
/cover/
|
||||||
|
|
||||||
|
# The directory Mix downloads your dependencies sources to.
|
||||||
|
/deps/
|
||||||
|
|
||||||
|
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||||
|
/.fetch
|
||||||
|
|
||||||
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Temporary files, for example, from tests.
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
|
mixer-*.tar
|
||||||
|
|
||||||
|
# Ignore assets that are produced by build tools.
|
||||||
|
/priv/static/assets/
|
||||||
|
|
||||||
|
# Ignore digested assets cache.
|
||||||
|
/priv/static/cache_manifest.json
|
||||||
|
|
||||||
|
# In case you use Node.js/npm, you want to ignore these.
|
||||||
|
npm-debug.log
|
||||||
|
/assets/node_modules/
|
||||||
|
|
||||||
10
.igniter.exs
Normal file
10
.igniter.exs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# This is a configuration file for igniter.
|
||||||
|
# For option documentation, see https://hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html
|
||||||
|
# To keep it up to date, use `mix igniter.setup`
|
||||||
|
[
|
||||||
|
module_location: :outside_matching_folder,
|
||||||
|
extensions: [{Igniter.Extensions.Phoenix, []}],
|
||||||
|
deps_location: :last_list_literal,
|
||||||
|
source_folders: ["lib", "test/support"],
|
||||||
|
dont_move_files: [~r"lib/mix"]
|
||||||
|
]
|
||||||
373
AGENTS.md
Normal file
373
AGENTS.md
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
This is a web application written using the Phoenix web framework.
|
||||||
|
|
||||||
|
## Project guidelines
|
||||||
|
|
||||||
|
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
|
||||||
|
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
|
||||||
|
|
||||||
|
### Phoenix v1.8 guidelines
|
||||||
|
|
||||||
|
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
|
||||||
|
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
|
||||||
|
- Anytime you run into errors with no `current_scope` assign:
|
||||||
|
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
|
||||||
|
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
|
||||||
|
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
|
||||||
|
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
|
||||||
|
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
|
||||||
|
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
|
||||||
|
custom classes must fully style the input
|
||||||
|
|
||||||
|
### JS and CSS guidelines
|
||||||
|
|
||||||
|
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
|
||||||
|
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
|
||||||
|
|
||||||
|
@import "tailwindcss" source(none);
|
||||||
|
@source "../css";
|
||||||
|
@source "../js";
|
||||||
|
@source "../../lib/my_app_web";
|
||||||
|
|
||||||
|
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
|
||||||
|
- **Never** use `@apply` when writing raw css
|
||||||
|
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
|
||||||
|
- Out of the box **only the app.js and app.css bundles are supported**
|
||||||
|
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
|
||||||
|
- You must import the vendor deps into app.js and app.css to use them
|
||||||
|
- **Never write inline <script>custom js</script> tags within templates**
|
||||||
|
|
||||||
|
### UI/UX & design guidelines
|
||||||
|
|
||||||
|
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
|
||||||
|
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
|
||||||
|
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
|
||||||
|
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
|
||||||
|
|
||||||
|
|
||||||
|
<!-- usage-rules-start -->
|
||||||
|
|
||||||
|
<!-- phoenix:elixir-start -->
|
||||||
|
## Elixir guidelines
|
||||||
|
|
||||||
|
- Elixir lists **do not support index based access via the access syntax**
|
||||||
|
|
||||||
|
**Never do this (invalid)**:
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
mylist = ["blue", "green"]
|
||||||
|
mylist[i]
|
||||||
|
|
||||||
|
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
mylist = ["blue", "green"]
|
||||||
|
Enum.at(mylist, i)
|
||||||
|
|
||||||
|
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
|
||||||
|
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
|
||||||
|
|
||||||
|
# INVALID: we are rebinding inside the `if` and the result never gets assigned
|
||||||
|
if connected?(socket) do
|
||||||
|
socket = assign(socket, :val, val)
|
||||||
|
end
|
||||||
|
|
||||||
|
# VALID: we rebind the result of the `if` to a new variable
|
||||||
|
socket =
|
||||||
|
if connected?(socket) do
|
||||||
|
assign(socket, :val, val)
|
||||||
|
end
|
||||||
|
|
||||||
|
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
|
||||||
|
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
|
||||||
|
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
|
||||||
|
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||||
|
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
|
||||||
|
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
|
||||||
|
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
|
||||||
|
|
||||||
|
## Mix guidelines
|
||||||
|
|
||||||
|
- Read the docs and options before using tasks (by using `mix help task_name`)
|
||||||
|
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
|
||||||
|
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
|
||||||
|
|
||||||
|
## Test guidelines
|
||||||
|
|
||||||
|
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
|
||||||
|
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
|
||||||
|
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
|
||||||
|
|
||||||
|
ref = Process.monitor(pid)
|
||||||
|
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
|
||||||
|
|
||||||
|
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
|
||||||
|
<!-- phoenix:elixir-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:phoenix-start -->
|
||||||
|
## Phoenix guidelines
|
||||||
|
|
||||||
|
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||||
|
|
||||||
|
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||||
|
|
||||||
|
scope "/admin", AppWeb.Admin do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
live "/users", UserLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||||
|
|
||||||
|
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||||
|
<!-- phoenix:phoenix-end -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- phoenix:html-start -->
|
||||||
|
## Phoenix HTML guidelines
|
||||||
|
|
||||||
|
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
|
||||||
|
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
|
||||||
|
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
|
||||||
|
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
|
||||||
|
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
|
||||||
|
|
||||||
|
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
|
||||||
|
|
||||||
|
**Never do this (invalid)**:
|
||||||
|
|
||||||
|
<%= if condition do %>
|
||||||
|
...
|
||||||
|
<% else if other_condition %>
|
||||||
|
...
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
Instead **always** do this:
|
||||||
|
|
||||||
|
<%= cond do %>
|
||||||
|
<% condition -> %>
|
||||||
|
...
|
||||||
|
<% condition2 -> %>
|
||||||
|
...
|
||||||
|
<% true -> %>
|
||||||
|
...
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
|
||||||
|
|
||||||
|
<code phx-no-curly-interpolation>
|
||||||
|
let obj = {key: "val"}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
|
||||||
|
|
||||||
|
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
|
||||||
|
|
||||||
|
<a class={[
|
||||||
|
"px-2 text-white",
|
||||||
|
@some_flag && "py-5",
|
||||||
|
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
|
||||||
|
...
|
||||||
|
]}>Text</a>
|
||||||
|
|
||||||
|
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
|
||||||
|
|
||||||
|
and **never** do this, since it's invalid (note the missing `[` and `]`):
|
||||||
|
|
||||||
|
<a class={
|
||||||
|
"px-2 text-white",
|
||||||
|
@some_flag && "py-5"
|
||||||
|
}> ...
|
||||||
|
=> Raises compile syntax error on invalid HEEx attr syntax
|
||||||
|
|
||||||
|
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
|
||||||
|
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
|
||||||
|
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
|
||||||
|
|
||||||
|
**Always** do this:
|
||||||
|
|
||||||
|
<div id={@id}>
|
||||||
|
{@my_assign}
|
||||||
|
<%= if @some_block_condition do %>
|
||||||
|
{@another_assign}
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
and **Never** do this – the program will terminate with a syntax error:
|
||||||
|
|
||||||
|
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
|
||||||
|
<div id="<%= @invalid_interpolation %>">
|
||||||
|
{if @invalid_block_construct do}
|
||||||
|
{end}
|
||||||
|
</div>
|
||||||
|
<!-- phoenix:html-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:liveview-start -->
|
||||||
|
## Phoenix LiveView guidelines
|
||||||
|
|
||||||
|
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
|
||||||
|
- **Avoid LiveComponent's** unless you have a strong, specific need for them
|
||||||
|
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
|
||||||
|
|
||||||
|
### LiveView streams
|
||||||
|
|
||||||
|
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
|
||||||
|
- basic append of N items - `stream(socket, :messages, [new_msg])`
|
||||||
|
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
|
||||||
|
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
|
||||||
|
- deleting items - `stream_delete(socket, :messages, msg)`
|
||||||
|
|
||||||
|
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
|
||||||
|
|
||||||
|
<div id="messages" phx-update="stream">
|
||||||
|
<div :for={{id, msg} <- @streams.messages} id={id}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
|
||||||
|
|
||||||
|
def handle_event("filter", %{"filter" => filter}, socket) do
|
||||||
|
# re-fetch the messages based on the filter
|
||||||
|
messages = list_messages(filter)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:messages_empty?, messages == [])
|
||||||
|
# reset the stream with the new messages
|
||||||
|
|> stream(:messages, messages, reset: true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
|
||||||
|
|
||||||
|
<div id="tasks" phx-update="stream">
|
||||||
|
<div class="hidden only:block">No tasks yet</div>
|
||||||
|
<div :for={{id, task} <- @streams.tasks} id={id}>
|
||||||
|
{task.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
|
||||||
|
|
||||||
|
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
|
||||||
|
along with the updated assign:
|
||||||
|
|
||||||
|
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
|
||||||
|
message = Chat.get_message!(message_id)
|
||||||
|
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
|
||||||
|
|
||||||
|
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:messages, message)
|
||||||
|
|> assign(:editing_message_id, String.to_integer(message_id))
|
||||||
|
|> assign(:edit_form, edit_form)}
|
||||||
|
end
|
||||||
|
|
||||||
|
And in the template:
|
||||||
|
|
||||||
|
<div id="messages" phx-update="stream">
|
||||||
|
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
|
||||||
|
{message.username}
|
||||||
|
<%= if @editing_message_id == message.id do %>
|
||||||
|
<%!-- Edit mode --%>
|
||||||
|
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
|
||||||
|
...
|
||||||
|
</.form>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
|
||||||
|
|
||||||
|
### LiveView JavaScript interop
|
||||||
|
|
||||||
|
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
|
||||||
|
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
|
||||||
|
|
||||||
|
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
|
||||||
|
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
|
||||||
|
|
||||||
|
#### Inline colocated js hooks
|
||||||
|
|
||||||
|
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
|
||||||
|
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
|
||||||
|
when writing scripts inside the template**:
|
||||||
|
|
||||||
|
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
|
||||||
|
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
|
||||||
|
export default {
|
||||||
|
mounted() {
|
||||||
|
this.el.addEventListener("input", e => {
|
||||||
|
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
|
||||||
|
if(match) {
|
||||||
|
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
- colocated hooks are automatically integrated into the app.js bundle
|
||||||
|
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
|
||||||
|
|
||||||
|
#### External phx-hook
|
||||||
|
|
||||||
|
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
|
||||||
|
LiveSocket constructor:
|
||||||
|
|
||||||
|
const MyHook = {
|
||||||
|
mounted() { ... }
|
||||||
|
}
|
||||||
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
|
hooks: { MyHook }
|
||||||
|
});
|
||||||
|
|
||||||
|
#### Pushing events between client and server
|
||||||
|
|
||||||
|
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
|
||||||
|
**Always** return or rebind the socket on `push_event/3` when pushing events:
|
||||||
|
|
||||||
|
# re-bind socket so we maintain event state to be pushed
|
||||||
|
socket = push_event(socket, "my_event", %{...})
|
||||||
|
|
||||||
|
# or return the modified socket directly:
|
||||||
|
def handle_event("some_event", _, socket) do
|
||||||
|
{:noreply, push_event(socket, "my_event", %{...})}
|
||||||
|
end
|
||||||
|
|
||||||
|
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.handleEvent("my_event", data => console.log("from server:", data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.el.addEventListener("click", e => {
|
||||||
|
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Where the server handled it via:
|
||||||
|
|
||||||
|
def handle_event("my_event", %{"one" => 1}, socket) do
|
||||||
|
{:reply, %{two: 2}, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
### LiveView tests
|
||||||
|
|
||||||
|
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
|
||||||
|
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
|
||||||
|
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
|
||||||
|
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
|
||||||
|
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
|
||||||
|
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
|
||||||
|
- Focus on testing outcomes rather than implementation details
|
||||||
|
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
|
||||||
|
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
document = LazyHTML.from_fragment(html)
|
||||||
|
matches = LazyHTML.filter(document, "your-complex-selector")
|
||||||
|
IO.inspect(matches, label: "Matches")
|
||||||
18
README.md
Normal file
18
README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Mixer
|
||||||
|
|
||||||
|
To start your Phoenix server:
|
||||||
|
|
||||||
|
* Run `mix setup` to install and setup dependencies
|
||||||
|
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||||
|
|
||||||
|
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||||
|
|
||||||
|
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
* Official website: https://www.phoenixframework.org/
|
||||||
|
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||||
|
* Docs: https://hexdocs.pm/phoenix
|
||||||
|
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||||
|
* Source: https://github.com/phoenixframework/phoenix
|
||||||
106
assets/css/app.css
Normal file
106
assets/css/app.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/* See the Tailwind configuration guide for advanced usage
|
||||||
|
https://tailwindcss.com/docs/configuration */
|
||||||
|
|
||||||
|
@import "tailwindcss" source(none);
|
||||||
|
@source "../../deps/ash_authentication_phoenix";
|
||||||
|
@source "../css";
|
||||||
|
@source "../js";
|
||||||
|
@source "../../lib/mixer_web";
|
||||||
|
|
||||||
|
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
|
||||||
|
The heroicons installation itself is managed by your mix.exs */
|
||||||
|
@plugin "../vendor/heroicons";
|
||||||
|
|
||||||
|
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
|
||||||
|
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
|
||||||
|
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
|
||||||
|
@plugin "../vendor/daisyui" {
|
||||||
|
themes: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
|
||||||
|
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||||
|
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
|
||||||
|
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
||||||
|
@plugin "../vendor/daisyui-theme" {
|
||||||
|
name: "dark";
|
||||||
|
default: false;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: "dark";
|
||||||
|
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||||
|
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||||
|
--color-base-300: oklch(20.15% 0.012 254.09);
|
||||||
|
--color-base-content: oklch(97.807% 0.029 256.847);
|
||||||
|
--color-primary: oklch(58% 0.233 277.117);
|
||||||
|
--color-primary-content: oklch(96% 0.018 272.314);
|
||||||
|
--color-secondary: oklch(58% 0.233 277.117);
|
||||||
|
--color-secondary-content: oklch(96% 0.018 272.314);
|
||||||
|
--color-accent: oklch(60% 0.25 292.717);
|
||||||
|
--color-accent-content: oklch(96% 0.016 293.756);
|
||||||
|
--color-neutral: oklch(37% 0.044 257.287);
|
||||||
|
--color-neutral-content: oklch(98% 0.003 247.858);
|
||||||
|
--color-info: oklch(58% 0.158 241.966);
|
||||||
|
--color-info-content: oklch(97% 0.013 236.62);
|
||||||
|
--color-success: oklch(60% 0.118 184.704);
|
||||||
|
--color-success-content: oklch(98% 0.014 180.72);
|
||||||
|
--color-warning: oklch(66% 0.179 58.318);
|
||||||
|
--color-warning-content: oklch(98% 0.022 95.277);
|
||||||
|
--color-error: oklch(58% 0.253 17.585);
|
||||||
|
--color-error-content: oklch(96% 0.015 12.422);
|
||||||
|
--radius-selector: 0.25rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
--size-selector: 0.21875rem;
|
||||||
|
--size-field: 0.21875rem;
|
||||||
|
--border: 1.5px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "../vendor/daisyui-theme" {
|
||||||
|
name: "light";
|
||||||
|
default: true;
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: "light";
|
||||||
|
--color-base-100: oklch(98% 0 0);
|
||||||
|
--color-base-200: oklch(96% 0.001 286.375);
|
||||||
|
--color-base-300: oklch(92% 0.004 286.32);
|
||||||
|
--color-base-content: oklch(21% 0.006 285.885);
|
||||||
|
--color-primary: oklch(70% 0.213 47.604);
|
||||||
|
--color-primary-content: oklch(98% 0.016 73.684);
|
||||||
|
--color-secondary: oklch(55% 0.027 264.364);
|
||||||
|
--color-secondary-content: oklch(98% 0.002 247.839);
|
||||||
|
--color-accent: oklch(0% 0 0);
|
||||||
|
--color-accent-content: oklch(100% 0 0);
|
||||||
|
--color-neutral: oklch(44% 0.017 285.786);
|
||||||
|
--color-neutral-content: oklch(98% 0 0);
|
||||||
|
--color-info: oklch(62% 0.214 259.815);
|
||||||
|
--color-info-content: oklch(97% 0.014 254.604);
|
||||||
|
--color-success: oklch(70% 0.14 182.503);
|
||||||
|
--color-success-content: oklch(98% 0.014 180.72);
|
||||||
|
--color-warning: oklch(66% 0.179 58.318);
|
||||||
|
--color-warning-content: oklch(98% 0.022 95.277);
|
||||||
|
--color-error: oklch(58% 0.253 17.585);
|
||||||
|
--color-error-content: oklch(96% 0.015 12.422);
|
||||||
|
--radius-selector: 0.25rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
--size-selector: 0.21875rem;
|
||||||
|
--size-field: 0.21875rem;
|
||||||
|
--border: 1.5px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add variants based on LiveView classes */
|
||||||
|
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||||
|
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||||
|
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||||
|
|
||||||
|
/* Use the data attribute for dark mode */
|
||||||
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
|
/* Make LiveView wrapper divs transparent for layout */
|
||||||
|
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
||||||
|
|
||||||
|
/* This file is for your main application CSS */
|
||||||
395
assets/js/animation.ts
Normal file
395
assets/js/animation.ts
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// AshTypescript landing page animation
|
||||||
|
// Typewriter effect with syntax highlighting, inspired by ash-hq.org
|
||||||
|
|
||||||
|
const HEXDOCS = "https://hexdocs.pm/ash_typescript";
|
||||||
|
|
||||||
|
interface Stage {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
docsPath: string;
|
||||||
|
elixir: string;
|
||||||
|
typescript: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages: Stage[] = [
|
||||||
|
{
|
||||||
|
name: "Type-Safe RPC",
|
||||||
|
description: "Auto-generated TypeScript functions for every Ash action with full type inference.",
|
||||||
|
docsPath: "/first-rpc-action.html",
|
||||||
|
elixir: `use Ash.Domain,
|
||||||
|
extensions: [AshTypescript.Rpc]
|
||||||
|
|
||||||
|
typescript_rpc do
|
||||||
|
resource MyApp.Todo do
|
||||||
|
rpc_action :list_todos, :read
|
||||||
|
rpc_action :create_todo, :create
|
||||||
|
rpc_action :update_todo, :update
|
||||||
|
end
|
||||||
|
end`,
|
||||||
|
typescript: `import { listTodos, createTodo } from "./ash_rpc";
|
||||||
|
|
||||||
|
const result = await listTodos({
|
||||||
|
fields: ["id", "title", { user: ["name"] }],
|
||||||
|
filter: { completed: { eq: false } },
|
||||||
|
sort: [{ field: "insertedAt", order: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// result.data is fully typed!
|
||||||
|
result.data.forEach(todo => {
|
||||||
|
console.log(todo.title, todo.user.name);
|
||||||
|
});
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Typed Controllers",
|
||||||
|
description: "Typed route helpers and fetch functions for Phoenix controllers.",
|
||||||
|
docsPath: "/typed-controllers.html",
|
||||||
|
elixir: `use AshTypescript.TypedController
|
||||||
|
|
||||||
|
typed_controller do
|
||||||
|
module_name MyAppWeb.SessionController
|
||||||
|
|
||||||
|
get :current_user do
|
||||||
|
run fn conn, _params ->
|
||||||
|
json(conn, current_user(conn))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post :login do
|
||||||
|
argument :email, :string, allow_nil?: false
|
||||||
|
argument :password, :string, allow_nil?: false
|
||||||
|
run fn conn, params ->
|
||||||
|
# authenticate and respond
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end`,
|
||||||
|
typescript: `import {
|
||||||
|
currentUserPath,
|
||||||
|
login,
|
||||||
|
} from "./routes";
|
||||||
|
|
||||||
|
// Typed path helpers
|
||||||
|
currentUserPath(); // => "/session/current_user"
|
||||||
|
|
||||||
|
// Typed fetch functions for mutations
|
||||||
|
const result = await login({
|
||||||
|
email: "user@example.com",
|
||||||
|
password: "secret",
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Typed Channels",
|
||||||
|
description: "Type-safe Phoenix channel event subscriptions with Ash PubSub.",
|
||||||
|
docsPath: "/typed-channels.html",
|
||||||
|
elixir: `# Resource with PubSub
|
||||||
|
pub_sub do
|
||||||
|
prefix "posts"
|
||||||
|
publish :create, [:id],
|
||||||
|
event: "post_created",
|
||||||
|
transform: :post_summary
|
||||||
|
end
|
||||||
|
|
||||||
|
# Typed channel definition
|
||||||
|
typed_channel do
|
||||||
|
topic "org:*"
|
||||||
|
|
||||||
|
resource MyApp.Post do
|
||||||
|
publish :post_created
|
||||||
|
publish :post_updated
|
||||||
|
end
|
||||||
|
end`,
|
||||||
|
typescript: `import {
|
||||||
|
createOrgChannel,
|
||||||
|
onOrgChannelMessages,
|
||||||
|
} from "./ash_typed_channels";
|
||||||
|
|
||||||
|
const channel = createOrgChannel(socket, orgId);
|
||||||
|
|
||||||
|
const refs = onOrgChannelMessages(channel, {
|
||||||
|
post_created: (payload) => {
|
||||||
|
// payload type inferred from calculation!
|
||||||
|
addPost(payload.id, payload.title);
|
||||||
|
},
|
||||||
|
post_updated: (payload) => {
|
||||||
|
updatePost(payload.id, payload.title);
|
||||||
|
},
|
||||||
|
});`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Field Selection",
|
||||||
|
description: "Request exactly the fields you need with full type narrowing.",
|
||||||
|
docsPath: "/field-selection.html",
|
||||||
|
elixir: `# Define your resource with relationships
|
||||||
|
attributes do
|
||||||
|
uuid_primary_key :id
|
||||||
|
attribute :title, :string, public?: true
|
||||||
|
attribute :body, :string, public?: true
|
||||||
|
attribute :view_count, :integer, public?: true
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
belongs_to :author, MyApp.User, public?: true
|
||||||
|
end
|
||||||
|
|
||||||
|
calculations do
|
||||||
|
calculate :reading_time, :integer,
|
||||||
|
expr(string_length(body) / 200)
|
||||||
|
end`,
|
||||||
|
typescript: `// Only fetch what you need - response is narrowed
|
||||||
|
const posts = await listPosts({
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"readingTime",
|
||||||
|
{ author: ["name", "avatarUrl"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// TypeScript knows the exact shape:
|
||||||
|
posts.data[0].title; // string ✓
|
||||||
|
posts.data[0].readingTime; // number ✓
|
||||||
|
posts.data[0].author.name; // string ✓
|
||||||
|
posts.data[0].body; // Error! Not selected`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Elixir syntax highlighting
|
||||||
|
function highlightElixir(code: string): string {
|
||||||
|
const tokens: { start: number; end: number; cls: string }[] = [];
|
||||||
|
const patterns: [RegExp, string][] = [
|
||||||
|
[/#[^\n]*/g, "text-gray-500"],
|
||||||
|
[/"[^"]*"/g, "text-yellow-400"],
|
||||||
|
[/\b(defmodule|def|defp|do|end|use|fn|if|else|case|cond|with|for|unless|import|alias|require)\b/g, "text-pink-400"],
|
||||||
|
[/\b(true|false|nil)\b/g, "text-purple-400"],
|
||||||
|
[/(:[a-zA-Z_][a-zA-Z0-9_?!]*)/g, "text-cyan-400"],
|
||||||
|
[/\b([A-Z][a-zA-Z0-9]*(\.[A-Z][a-zA-Z0-9]*)*)\b/g, "text-blue-400"],
|
||||||
|
[/(\|>|->|<-|=>)/g, "text-pink-400"],
|
||||||
|
];
|
||||||
|
for (const [re, cls] of patterns) {
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(code)) !== null) {
|
||||||
|
const overlaps = tokens.some(t => m!.index < t.end && m!.index + m![0].length > t.start);
|
||||||
|
if (!overlaps) tokens.push({ start: m.index, end: m.index + m[0].length, cls });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.sort((a, b) => a.start - b.start);
|
||||||
|
let result = "";
|
||||||
|
let pos = 0;
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.start > pos) result += esc(code.slice(pos, t.start));
|
||||||
|
result += `<span class="${t.cls}">${esc(code.slice(t.start, t.end))}</span>`;
|
||||||
|
pos = t.end;
|
||||||
|
}
|
||||||
|
if (pos < code.length) result += esc(code.slice(pos));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript syntax highlighting
|
||||||
|
function highlightTS(code: string): string {
|
||||||
|
const tokens: { start: number; end: number; cls: string }[] = [];
|
||||||
|
const patterns: [RegExp, string][] = [
|
||||||
|
[/\/\/[^\n]*/g, "text-gray-500"],
|
||||||
|
[/"[^"]*"/g, "text-yellow-400"],
|
||||||
|
[/`[^`]*`/g, "text-yellow-400"],
|
||||||
|
[/\b(import|from|export|const|let|var|function|return|if|else|async|await|new|typeof|interface|type)\b/g, "text-pink-400"],
|
||||||
|
[/\b(true|false|null|undefined)\b/g, "text-purple-400"],
|
||||||
|
[/(=>)/g, "text-pink-400"],
|
||||||
|
];
|
||||||
|
for (const [re, cls] of patterns) {
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(code)) !== null) {
|
||||||
|
const overlaps = tokens.some(t => m!.index < t.end && m!.index + m![0].length > t.start);
|
||||||
|
if (!overlaps) tokens.push({ start: m.index, end: m.index + m[0].length, cls });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.sort((a, b) => a.start - b.start);
|
||||||
|
let result = "";
|
||||||
|
let pos = 0;
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.start > pos) result += esc(code.slice(pos, t.start));
|
||||||
|
result += `<span class="${t.cls}">${esc(code.slice(t.start, t.end))}</span>`;
|
||||||
|
pos = t.end;
|
||||||
|
}
|
||||||
|
if (pos < code.length) result += esc(code.slice(pos));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typewriter engine
|
||||||
|
function typewrite(
|
||||||
|
el: HTMLElement,
|
||||||
|
highlighted: string,
|
||||||
|
onComplete: () => void,
|
||||||
|
signal: { cancelled: boolean },
|
||||||
|
): void {
|
||||||
|
// Build a flat list of characters with their HTML wrapping
|
||||||
|
const chars: string[] = [];
|
||||||
|
let inTag = false;
|
||||||
|
let tagBuffer = "";
|
||||||
|
let openTags: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < highlighted.length; i++) {
|
||||||
|
const ch = highlighted[i];
|
||||||
|
if (ch === "<") {
|
||||||
|
inTag = true;
|
||||||
|
tagBuffer = "<";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag) {
|
||||||
|
tagBuffer += ch;
|
||||||
|
if (ch === ">") {
|
||||||
|
inTag = false;
|
||||||
|
if (tagBuffer.startsWith("</")) {
|
||||||
|
openTags.pop();
|
||||||
|
} else {
|
||||||
|
openTags.push(tagBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Handle HTML entities as single visible characters
|
||||||
|
let visibleChar = ch;
|
||||||
|
if (ch === "&") {
|
||||||
|
const semiIdx = highlighted.indexOf(";", i);
|
||||||
|
if (semiIdx !== -1 && semiIdx - i < 8) {
|
||||||
|
visibleChar = highlighted.slice(i, semiIdx + 1);
|
||||||
|
i = semiIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let wrapped = visibleChar;
|
||||||
|
for (const tag of openTags) {
|
||||||
|
const cls = tag.match(/class="([^"]*)"/)?.[1] || "";
|
||||||
|
wrapped = `<span class="${cls}">${wrapped}</span>`;
|
||||||
|
}
|
||||||
|
chars.push(wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
el.innerHTML = '<span class="inline-block w-[2px] h-[1.1em] bg-primary align-text-bottom animate-pulse"></span>';
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
if (signal.cancelled) return;
|
||||||
|
if (idx >= chars.length) {
|
||||||
|
el.innerHTML = highlighted;
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Insert character before cursor
|
||||||
|
const cursor = el.querySelector("span:last-child")!;
|
||||||
|
cursor.insertAdjacentHTML("beforebegin", chars[idx]);
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
const c = chars[idx - 1];
|
||||||
|
const isNewline = c.includes("\n") || c === "\n";
|
||||||
|
const isSpace = c === " " || c.endsWith("> </span>");
|
||||||
|
const delay = isNewline ? 80 : isSpace ? 15 : 25 + Math.random() * 15;
|
||||||
|
setTimeout(step, delay);
|
||||||
|
}
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main initialization
|
||||||
|
export function initLandingPage(container: HTMLElement): () => void {
|
||||||
|
let currentStage = 0;
|
||||||
|
let signal = { cancelled: false };
|
||||||
|
let autoTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
container.innerHTML = buildHTML();
|
||||||
|
const elixirEl = container.querySelector<HTMLElement>("#elixir-code")!;
|
||||||
|
const tsEl = container.querySelector<HTMLElement>("#ts-code")!;
|
||||||
|
const descEl = container.querySelector<HTMLElement>("#stage-description")!;
|
||||||
|
const docsLink = container.querySelector<HTMLAnchorElement>("#stage-docs-link")!;
|
||||||
|
const dots = container.querySelectorAll<HTMLElement>(".stage-dot");
|
||||||
|
const tsPanel = container.querySelector<HTMLElement>("#ts-panel")!;
|
||||||
|
|
||||||
|
function showStage(index: number) {
|
||||||
|
signal.cancelled = true;
|
||||||
|
signal = { cancelled: false };
|
||||||
|
if (autoTimer) clearTimeout(autoTimer);
|
||||||
|
currentStage = index;
|
||||||
|
const stage = stages[index];
|
||||||
|
|
||||||
|
// Update dots
|
||||||
|
dots.forEach((dot, i) => {
|
||||||
|
dot.classList.toggle("bg-primary", i === index);
|
||||||
|
dot.classList.toggle("opacity-100", i === index);
|
||||||
|
dot.classList.toggle("bg-base-content", i !== index);
|
||||||
|
dot.classList.toggle("opacity-30", i !== index);
|
||||||
|
dot.classList.toggle("scale-125", i === index);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update description
|
||||||
|
descEl.textContent = stage.description;
|
||||||
|
docsLink.href = HEXDOCS + stage.docsPath;
|
||||||
|
|
||||||
|
// Reset panels — hide TS panel entirely (no layout space)
|
||||||
|
tsPanel.hidden = true;
|
||||||
|
tsPanel.style.opacity = "0";
|
||||||
|
|
||||||
|
const elixirHL = highlightElixir(stage.elixir);
|
||||||
|
const tsHL = highlightTS(stage.typescript);
|
||||||
|
|
||||||
|
if (reducedMotion) {
|
||||||
|
elixirEl.innerHTML = elixirHL;
|
||||||
|
tsEl.innerHTML = tsHL;
|
||||||
|
tsPanel.hidden = false;
|
||||||
|
tsPanel.style.opacity = "1";
|
||||||
|
autoTimer = setTimeout(() => showStage((currentStage + 1) % stages.length), 6000);
|
||||||
|
} else {
|
||||||
|
typewrite(elixirEl, elixirHL, () => {
|
||||||
|
if (signal.cancelled) return;
|
||||||
|
tsPanel.hidden = false;
|
||||||
|
requestAnimationFrame(() => { tsPanel.style.opacity = "1"; });
|
||||||
|
typewrite(tsEl, tsHL, () => {
|
||||||
|
if (signal.cancelled) return;
|
||||||
|
autoTimer = setTimeout(() => showStage((currentStage + 1) % stages.length), 4000);
|
||||||
|
}, signal);
|
||||||
|
}, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot click handlers
|
||||||
|
dots.forEach((dot, i) => {
|
||||||
|
dot.addEventListener("click", () => showStage(i));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start
|
||||||
|
showStage(0);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
signal.cancelled = true;
|
||||||
|
if (autoTimer) clearTimeout(autoTimer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHTML(): string {
|
||||||
|
const dotHTML = stages.map((s, i) =>
|
||||||
|
`<button class="stage-dot w-3 h-3 rounded-full transition-all duration-300 cursor-pointer ${i === 0 ? "bg-primary opacity-100 scale-125" : "bg-base-content opacity-30"}" title="${s.name}"></button>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mb-12">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex gap-2 items-center">${dotHTML}</div>
|
||||||
|
<a id="stage-docs-link" href="${HEXDOCS}" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">View docs →</a>
|
||||||
|
</div>
|
||||||
|
<p id="stage-description" class="text-sm opacity-70 mb-4">${stages[0].description}</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute top-2 right-3 text-xs opacity-40 font-mono select-none">Elixir</div>
|
||||||
|
<pre class="bg-base-300 rounded-lg p-4 pt-8 text-sm font-mono leading-relaxed overflow-x-auto"><code id="elixir-code" class="text-gray-300"></code></pre>
|
||||||
|
</div>
|
||||||
|
<div id="ts-panel" class="relative transition-opacity duration-500" style="opacity:0" hidden>
|
||||||
|
<div class="absolute top-2 right-3 text-xs opacity-40 font-mono select-none">TypeScript</div>
|
||||||
|
<pre class="bg-base-300 rounded-lg p-4 pt-8 text-sm font-mono leading-relaxed overflow-x-auto"><code id="ts-code" class="text-gray-300"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
82
assets/js/app.js
Normal file
82
assets/js/app.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||||
|
// to get started and then uncomment the line below.
|
||||||
|
// import "./user_socket.js"
|
||||||
|
|
||||||
|
// You can include dependencies in two ways.
|
||||||
|
//
|
||||||
|
// The simplest option is to put them in assets/vendor and
|
||||||
|
// import them using relative paths:
|
||||||
|
//
|
||||||
|
// import "../vendor/some-package.js"
|
||||||
|
//
|
||||||
|
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||||
|
// them using a path starting with the package name:
|
||||||
|
//
|
||||||
|
// import "some-package"
|
||||||
|
//
|
||||||
|
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
|
||||||
|
// To load it, simply add a second `<link>` to your `root.html.heex` file.
|
||||||
|
|
||||||
|
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||||
|
import "phoenix_html"
|
||||||
|
// Establish Phoenix Socket and LiveView configuration.
|
||||||
|
import {Socket} from "phoenix"
|
||||||
|
import {LiveSocket} from "phoenix_live_view"
|
||||||
|
import {hooks as colocatedHooks} from "phoenix-colocated/mixer"
|
||||||
|
import topbar from "topbar"
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
|
longPollFallbackMs: 2500,
|
||||||
|
params: {_csrf_token: csrfToken},
|
||||||
|
hooks: {...colocatedHooks},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show progress bar on live navigation and form submits
|
||||||
|
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||||
|
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||||
|
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||||
|
|
||||||
|
// connect if there are any LiveViews on the page
|
||||||
|
liveSocket.connect()
|
||||||
|
|
||||||
|
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||||
|
// >> liveSocket.enableDebug()
|
||||||
|
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||||
|
// >> liveSocket.disableLatencySim()
|
||||||
|
window.liveSocket = liveSocket
|
||||||
|
|
||||||
|
// The lines below enable quality of life phoenix_live_reload
|
||||||
|
// development features:
|
||||||
|
//
|
||||||
|
// 1. stream server logs to the browser console
|
||||||
|
// 2. click on elements to jump to their definitions in your code editor
|
||||||
|
//
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
|
||||||
|
// Enable server log streaming to client.
|
||||||
|
// Disable with reloader.disableServerLogs()
|
||||||
|
reloader.enableServerLogs()
|
||||||
|
|
||||||
|
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
||||||
|
//
|
||||||
|
// * click with "c" key pressed to open at caller location
|
||||||
|
// * click with "d" key pressed to open at function component definition location
|
||||||
|
let keyDown
|
||||||
|
window.addEventListener("keydown", e => keyDown = e.key)
|
||||||
|
window.addEventListener("keyup", _e => keyDown = null)
|
||||||
|
window.addEventListener("click", e => {
|
||||||
|
if(keyDown === "c"){
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
reloader.openEditorAtCaller(e.target)
|
||||||
|
} else if(keyDown === "d"){
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
reloader.openEditorAtDef(e.target)
|
||||||
|
}
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
window.liveReloader = reloader
|
||||||
|
})
|
||||||
|
}
|
||||||
201
assets/js/ash_rpc.ts
Normal file
201
assets/js/ash_rpc.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Generated by AshTypescript - RPC Actions
|
||||||
|
// Do not edit this file manually
|
||||||
|
|
||||||
|
|
||||||
|
export type * from "./ash_types";
|
||||||
|
|
||||||
|
// Helper Functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for action RPC requests
|
||||||
|
*/
|
||||||
|
export interface ActionConfig {
|
||||||
|
// Request data
|
||||||
|
input?: Record<string, any>;
|
||||||
|
identity?: any;
|
||||||
|
fields?: Array<string | Record<string, any>>; // Field selection
|
||||||
|
filter?: Record<string, any>; // Filter options (for reads)
|
||||||
|
sort?: string | string[]; // Sort options
|
||||||
|
page?:
|
||||||
|
| {
|
||||||
|
// Offset-based pagination
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
count?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Keyset pagination
|
||||||
|
limit?: number;
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
metadataFields?: ReadonlyArray<string>;
|
||||||
|
|
||||||
|
// HTTP customization
|
||||||
|
headers?: Record<string, string>; // Custom headers
|
||||||
|
fetchOptions?: RequestInit; // Fetch options (signal, cache, etc.)
|
||||||
|
customFetch?: (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
// Multitenancy
|
||||||
|
tenant?: string; // Tenant parameter
|
||||||
|
|
||||||
|
// Hook context
|
||||||
|
hookCtx?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for validation RPC requests
|
||||||
|
*/
|
||||||
|
export interface ValidationConfig {
|
||||||
|
// Request data
|
||||||
|
input?: Record<string, any>;
|
||||||
|
|
||||||
|
// HTTP customization
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
// Hook context
|
||||||
|
hookCtx?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the CSRF token from the page's meta tag
|
||||||
|
* Returns null if no CSRF token is found
|
||||||
|
*/
|
||||||
|
export function getPhoenixCSRFToken(): string | null {
|
||||||
|
return document
|
||||||
|
?.querySelector("meta[name='csrf-token']")
|
||||||
|
?.getAttribute("content") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds headers object with CSRF token for Phoenix applications
|
||||||
|
* Returns headers object with X-CSRF-Token (if available)
|
||||||
|
*/
|
||||||
|
export function buildCSRFHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
||||||
|
const csrfToken = getPhoenixCSRFToken();
|
||||||
|
if (csrfToken) {
|
||||||
|
headers["X-CSRF-Token"] = csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper function for making action RPC requests
|
||||||
|
* Handles hooks, request configuration, fetch execution, and error handling
|
||||||
|
* @param config Configuration matching ActionConfig
|
||||||
|
*/
|
||||||
|
export async function executeActionRpcRequest<T>(
|
||||||
|
payload: Record<string, any>,
|
||||||
|
config: ActionConfig
|
||||||
|
): Promise<T> {
|
||||||
|
const processedConfig = config;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...processedConfig.headers,
|
||||||
|
...config.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFunction = config.customFetch || processedConfig.customFetch || fetch;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
...processedConfig.fetchOptions,
|
||||||
|
...config.fetchOptions,
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchFunction("/rpc/run", fetchOptions);
|
||||||
|
const result = response.ok ? await response.json() : null;
|
||||||
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: "network_error",
|
||||||
|
message: `Network request failed: ${response.statusText}`,
|
||||||
|
shortMessage: "Network error",
|
||||||
|
vars: { statusCode: response.status, statusText: response.statusText },
|
||||||
|
fields: [],
|
||||||
|
path: [],
|
||||||
|
details: { statusCode: response.status }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper function for making validation RPC requests
|
||||||
|
* Handles hooks, request configuration, fetch execution, and error handling
|
||||||
|
* @param config Configuration matching ValidationConfig
|
||||||
|
*/
|
||||||
|
export async function executeValidationRpcRequest<T>(
|
||||||
|
payload: Record<string, any>,
|
||||||
|
config: ValidationConfig
|
||||||
|
): Promise<T> {
|
||||||
|
const processedConfig = config;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...processedConfig.headers,
|
||||||
|
...config.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFunction = config.customFetch || processedConfig.customFetch || fetch;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
...processedConfig.fetchOptions,
|
||||||
|
...config.fetchOptions,
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchFunction("/rpc/validate", fetchOptions);
|
||||||
|
const result = response.ok ? await response.json() : null;
|
||||||
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: "network_error",
|
||||||
|
message: `Network request failed: ${response.statusText}`,
|
||||||
|
shortMessage: "Network error",
|
||||||
|
vars: { statusCode: response.status, statusText: response.statusText },
|
||||||
|
fields: [],
|
||||||
|
path: [],
|
||||||
|
details: { statusCode: response.status }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
440
assets/js/ash_types.ts
Normal file
440
assets/js/ash_types.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// Generated by AshTypescript - Shared Types
|
||||||
|
// Do not edit this file manually
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Utility Types
|
||||||
|
|
||||||
|
// Sort string type — allows optional direction prefix on sort field names
|
||||||
|
// Prefixes per Ash.Query.sort/3: + (asc), - (desc), ++ (asc_nils_first), -- (desc_nils_last)
|
||||||
|
export type SortString<T extends string> = T | `+${T}` | `-${T}` | `++${T}` | `--${T}`;
|
||||||
|
|
||||||
|
// Resource schema constraint
|
||||||
|
export type TypedSchema = {
|
||||||
|
__type: "Resource" | "TypedMap" | "Union";
|
||||||
|
__primitiveFields: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility type to convert union to intersection
|
||||||
|
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||||
|
k: infer I,
|
||||||
|
) => void
|
||||||
|
? I
|
||||||
|
: never;
|
||||||
|
|
||||||
|
// Helper type to infer union field values, avoiding duplication between array and non-array unions
|
||||||
|
export type InferUnionFieldValue<
|
||||||
|
UnionSchema extends { __type: "Union"; __primitiveFields: any },
|
||||||
|
FieldSelection extends any[],
|
||||||
|
> = UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIndex in keyof FieldSelection]: FieldSelection[FieldIndex] extends UnionSchema["__primitiveFields"]
|
||||||
|
? FieldSelection[FieldIndex] extends keyof UnionSchema
|
||||||
|
? { [P in FieldSelection[FieldIndex]]: UnionSchema[FieldSelection[FieldIndex]] }
|
||||||
|
: never
|
||||||
|
: FieldSelection[FieldIndex] extends Record<string, any>
|
||||||
|
? {
|
||||||
|
[UnionKey in keyof FieldSelection[FieldIndex]]: UnionKey extends keyof UnionSchema
|
||||||
|
? NonNullable<UnionSchema[UnionKey]> extends { __array: true; __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
|
||||||
|
? FieldSelection[FieldIndex][UnionKey] extends any[]
|
||||||
|
? Array<
|
||||||
|
UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields
|
||||||
|
? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof NonNullable<UnionSchema[UnionKey]>
|
||||||
|
? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: NonNullable<UnionSchema[UnionKey]>[P] }
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
>
|
||||||
|
> | null
|
||||||
|
: never
|
||||||
|
: NonNullable<UnionSchema[UnionKey]> extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
|
||||||
|
? FieldSelection[FieldIndex][UnionKey] extends any[]
|
||||||
|
? UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields
|
||||||
|
? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof NonNullable<UnionSchema[UnionKey]>
|
||||||
|
? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: NonNullable<UnionSchema[UnionKey]>[P] }
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
> | null
|
||||||
|
: never
|
||||||
|
: NonNullable<UnionSchema[UnionKey]> extends TypedSchema
|
||||||
|
? InferResult<NonNullable<UnionSchema[UnionKey]>, FieldSelection[FieldIndex][UnionKey]>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type HasComplexFields<T extends TypedSchema> = keyof Omit<
|
||||||
|
T,
|
||||||
|
"__primitiveFields" | "__type" | T["__primitiveFields"]
|
||||||
|
> extends never
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
|
|
||||||
|
export type ComplexFieldKeys<T extends TypedSchema> = keyof Omit<
|
||||||
|
T,
|
||||||
|
"__primitiveFields" | "__type" | T["__primitiveFields"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type LeafFieldSelection<T extends TypedSchema> = T["__primitiveFields"];
|
||||||
|
|
||||||
|
export type ComplexFieldSelection<T extends TypedSchema> = {
|
||||||
|
[K in ComplexFieldKeys<T>]?: T[K] extends {
|
||||||
|
__type: "Relationship";
|
||||||
|
__resource: infer Resource;
|
||||||
|
}
|
||||||
|
? NonNullable<Resource> extends TypedSchema
|
||||||
|
? UnifiedFieldSelection<NonNullable<Resource>>[]
|
||||||
|
: never
|
||||||
|
: T[K] extends {
|
||||||
|
__type: "ComplexCalculation";
|
||||||
|
__returnType: infer ReturnType;
|
||||||
|
}
|
||||||
|
? T[K] extends { __args: infer Args }
|
||||||
|
? NonNullable<ReturnType> extends TypedSchema
|
||||||
|
? {
|
||||||
|
args: Args;
|
||||||
|
fields: UnifiedFieldSelection<NonNullable<ReturnType>>[];
|
||||||
|
}
|
||||||
|
: { args: Args }
|
||||||
|
: NonNullable<ReturnType> extends TypedSchema
|
||||||
|
? { fields: UnifiedFieldSelection<NonNullable<ReturnType>>[] }
|
||||||
|
: never
|
||||||
|
: T[K] extends { __type: "TypedMap" }
|
||||||
|
? NonNullable<T[K]> extends TypedSchema
|
||||||
|
? UnifiedFieldSelection<NonNullable<T[K]>>[]
|
||||||
|
: never
|
||||||
|
: T[K] extends { __type: "Union"; __primitiveFields: infer PrimitiveFields }
|
||||||
|
? T[K] extends { __array: true }
|
||||||
|
? (PrimitiveFields | {
|
||||||
|
[UnionKey in keyof Omit<T[K], "__type" | "__primitiveFields" | "__array">]?: NonNullable<T[K][UnionKey]> extends { __type: "TypedMap"; __primitiveFields: any }
|
||||||
|
? NonNullable<T[K][UnionKey]>["__primitiveFields"][]
|
||||||
|
: NonNullable<T[K][UnionKey]> extends TypedSchema
|
||||||
|
? UnifiedFieldSelection<NonNullable<T[K][UnionKey]>>[]
|
||||||
|
: never;
|
||||||
|
})[]
|
||||||
|
: (PrimitiveFields | {
|
||||||
|
[UnionKey in keyof Omit<T[K], "__type" | "__primitiveFields">]?: NonNullable<T[K][UnionKey]> extends { __type: "TypedMap"; __primitiveFields: any }
|
||||||
|
? NonNullable<T[K][UnionKey]>["__primitiveFields"][]
|
||||||
|
: NonNullable<T[K][UnionKey]> extends TypedSchema
|
||||||
|
? UnifiedFieldSelection<NonNullable<T[K][UnionKey]>>[]
|
||||||
|
: never;
|
||||||
|
})[]
|
||||||
|
: NonNullable<T[K]> extends TypedSchema
|
||||||
|
? UnifiedFieldSelection<NonNullable<T[K]>>[]
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main type: Use explicit base case detection to prevent infinite recursion
|
||||||
|
export type UnifiedFieldSelection<T extends TypedSchema> =
|
||||||
|
HasComplexFields<T> extends false
|
||||||
|
? LeafFieldSelection<T> // Base case: only primitives, no recursion
|
||||||
|
: LeafFieldSelection<T> | ComplexFieldSelection<T>; // Recursive case
|
||||||
|
|
||||||
|
export type InferFieldValue<
|
||||||
|
T extends TypedSchema,
|
||||||
|
Field,
|
||||||
|
> = Field extends T["__primitiveFields"]
|
||||||
|
? Field extends keyof T
|
||||||
|
? { [K in Field]: T[Field] }
|
||||||
|
: never
|
||||||
|
: Field extends Record<string, any>
|
||||||
|
? {
|
||||||
|
[K in keyof Field]: K extends keyof T
|
||||||
|
? T[K] extends {
|
||||||
|
__type: "Relationship";
|
||||||
|
__resource: infer Resource;
|
||||||
|
}
|
||||||
|
? NonNullable<Resource> extends TypedSchema
|
||||||
|
? T[K] extends { __array: true }
|
||||||
|
? Array<InferResult<NonNullable<Resource>, Field[K]>>
|
||||||
|
: null extends Resource
|
||||||
|
? InferResult<NonNullable<Resource>, Field[K]> | null
|
||||||
|
: InferResult<NonNullable<Resource>, Field[K]>
|
||||||
|
: never
|
||||||
|
: T[K] extends {
|
||||||
|
__type: "ComplexCalculation";
|
||||||
|
__returnType: infer ReturnType;
|
||||||
|
}
|
||||||
|
? NonNullable<ReturnType> extends TypedSchema
|
||||||
|
? null extends ReturnType
|
||||||
|
? InferResult<NonNullable<ReturnType>, Field[K]["fields"]> | null
|
||||||
|
: InferResult<NonNullable<ReturnType>, Field[K]["fields"]>
|
||||||
|
: ReturnType
|
||||||
|
: NonNullable<T[K]> extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
|
||||||
|
? NonNullable<T[K]> extends { __array: true }
|
||||||
|
? Field[K] extends any[]
|
||||||
|
? null extends T[K]
|
||||||
|
? Array<
|
||||||
|
UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||||
|
? E extends TypedMapFields
|
||||||
|
? E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: E extends Record<string, any>
|
||||||
|
? {
|
||||||
|
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||||
|
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||||
|
? null extends NonNullable<T[K]>[NestedKey]
|
||||||
|
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||||
|
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
>
|
||||||
|
> | null
|
||||||
|
: Array<
|
||||||
|
UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||||
|
? E extends TypedMapFields
|
||||||
|
? E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: E extends Record<string, any>
|
||||||
|
? {
|
||||||
|
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||||
|
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||||
|
? null extends NonNullable<T[K]>[NestedKey]
|
||||||
|
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||||
|
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
>
|
||||||
|
>
|
||||||
|
: never
|
||||||
|
: Field[K] extends any[]
|
||||||
|
? null extends T[K]
|
||||||
|
? UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||||
|
? E extends TypedMapFields
|
||||||
|
? E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: E extends Record<string, any>
|
||||||
|
? {
|
||||||
|
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||||
|
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||||
|
? null extends NonNullable<T[K]>[NestedKey]
|
||||||
|
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||||
|
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
> | null
|
||||||
|
: UnionToIntersection<
|
||||||
|
{
|
||||||
|
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||||
|
? E extends TypedMapFields
|
||||||
|
? E extends keyof T[K]
|
||||||
|
? { [P in E]: T[K][P] }
|
||||||
|
: never
|
||||||
|
: E extends Record<string, any>
|
||||||
|
? {
|
||||||
|
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||||
|
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||||
|
? null extends NonNullable<T[K]>[NestedKey]
|
||||||
|
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||||
|
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: E extends keyof NonNullable<T[K]>
|
||||||
|
? { [P in E]: NonNullable<T[K]>[P] }
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}[number]
|
||||||
|
>
|
||||||
|
: never
|
||||||
|
: T[K] extends { __type: "Union"; __primitiveFields: any }
|
||||||
|
? T[K] extends { __array: true }
|
||||||
|
? Field[K] extends any[]
|
||||||
|
? null extends T[K]
|
||||||
|
? Array<InferUnionFieldValue<T[K], Field[K]>> | null
|
||||||
|
: Array<InferUnionFieldValue<T[K], Field[K]>>
|
||||||
|
: never
|
||||||
|
: Field[K] extends any[]
|
||||||
|
? null extends T[K]
|
||||||
|
? InferUnionFieldValue<T[K], Field[K]> | null
|
||||||
|
: InferUnionFieldValue<T[K], Field[K]>
|
||||||
|
: never
|
||||||
|
: NonNullable<T[K]> extends TypedSchema
|
||||||
|
? null extends T[K]
|
||||||
|
? InferResult<NonNullable<T[K]>, Field[K]> | null
|
||||||
|
: InferResult<NonNullable<T[K]>, Field[K]>
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type InferResult<
|
||||||
|
T extends TypedSchema,
|
||||||
|
SelectedFields extends UnifiedFieldSelection<T>[] | undefined,
|
||||||
|
> = SelectedFields extends undefined
|
||||||
|
? {}
|
||||||
|
: SelectedFields extends []
|
||||||
|
? {}
|
||||||
|
: SelectedFields extends UnifiedFieldSelection<T>[]
|
||||||
|
? UnionToIntersection<
|
||||||
|
{
|
||||||
|
[K in keyof SelectedFields]: InferFieldValue<T, SelectedFields[K]>;
|
||||||
|
}[number]
|
||||||
|
>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Pagination conditional types
|
||||||
|
// Checks if a page configuration object has any pagination parameters
|
||||||
|
export type HasPaginationParams<Page> =
|
||||||
|
Page extends { offset: any } ? true :
|
||||||
|
Page extends { after: any } ? true :
|
||||||
|
Page extends { before: any } ? true :
|
||||||
|
false;
|
||||||
|
|
||||||
|
// Infer which pagination type is being used from the page config
|
||||||
|
export type InferPaginationType<Page> =
|
||||||
|
Page extends { offset: any } ? "offset" :
|
||||||
|
Page extends { after: any } | { before: any } ? "keyset" :
|
||||||
|
never;
|
||||||
|
|
||||||
|
// Returns either non-paginated (array) or paginated result based on page params
|
||||||
|
// For single pagination type support (offset-only or keyset-only)
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export type ConditionalPaginatedResult<
|
||||||
|
Page,
|
||||||
|
RecordType,
|
||||||
|
PaginatedType
|
||||||
|
> = Page extends undefined
|
||||||
|
? RecordType
|
||||||
|
: HasPaginationParams<Page> extends true
|
||||||
|
? PaginatedType
|
||||||
|
: RecordType;
|
||||||
|
|
||||||
|
// For actions supporting both offset and keyset pagination
|
||||||
|
// Infers the specific pagination type based on which params were passed
|
||||||
|
export type ConditionalPaginatedResultMixed<
|
||||||
|
Page,
|
||||||
|
RecordType,
|
||||||
|
OffsetType,
|
||||||
|
KeysetType
|
||||||
|
> = Page extends undefined
|
||||||
|
? RecordType
|
||||||
|
: HasPaginationParams<Page> extends true
|
||||||
|
? InferPaginationType<Page> extends "offset"
|
||||||
|
? OffsetType
|
||||||
|
: InferPaginationType<Page> extends "keyset"
|
||||||
|
? KeysetType
|
||||||
|
: OffsetType | KeysetType // Fallback to union if can't determine
|
||||||
|
: RecordType;
|
||||||
|
|
||||||
|
export type SuccessDataFunc<T extends (...args: any[]) => Promise<any>> = Extract<
|
||||||
|
Awaited<ReturnType<T>>,
|
||||||
|
{ success: true }
|
||||||
|
>["data"];
|
||||||
|
|
||||||
|
|
||||||
|
export type ErrorData<T extends (...args: any[]) => Promise<any>> = Extract<
|
||||||
|
Awaited<ReturnType<T>>,
|
||||||
|
{ success: false }
|
||||||
|
>["errors"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an error from an unsuccessful RPC call.
|
||||||
|
*
|
||||||
|
* This type matches the error structure defined in the AshTypescript.Rpc.Error protocol.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const error: AshRpcError = {
|
||||||
|
* type: "invalid_changes",
|
||||||
|
* message: "Invalid value for field %{field}",
|
||||||
|
* shortMessage: "Invalid changes",
|
||||||
|
* vars: { field: "email" },
|
||||||
|
* fields: ["email"],
|
||||||
|
* path: ["user", "email"],
|
||||||
|
* details: { suggestion: "Provide a valid email address" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export type AshRpcError = {
|
||||||
|
/** Machine-readable error type (e.g., "invalid_changes", "not_found") */
|
||||||
|
type: string;
|
||||||
|
/** Full error message (may contain template variables like %{key}) */
|
||||||
|
message: string;
|
||||||
|
/** Concise version of the message */
|
||||||
|
shortMessage: string;
|
||||||
|
/** Variables to interpolate into the message template */
|
||||||
|
vars: Record<string, any>;
|
||||||
|
/** List of affected field names (for field-level errors) */
|
||||||
|
fields: string[];
|
||||||
|
/** Path to the error location in the data structure */
|
||||||
|
path: string[];
|
||||||
|
/** Optional map with extra details (e.g., suggestions, hints) */
|
||||||
|
details?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the result of a validation RPC call.
|
||||||
|
*
|
||||||
|
* All validation actions return this same structure, indicating either
|
||||||
|
* successful validation or a list of validation errors.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Successful validation
|
||||||
|
* const result: ValidationResult = { success: true };
|
||||||
|
*
|
||||||
|
* // Failed validation
|
||||||
|
* const result: ValidationResult = {
|
||||||
|
* success: false,
|
||||||
|
* errors: [
|
||||||
|
* {
|
||||||
|
* type: "required",
|
||||||
|
* message: "is required",
|
||||||
|
* shortMessage: "Required field",
|
||||||
|
* vars: { field: "email" },
|
||||||
|
* fields: ["email"],
|
||||||
|
* path: []
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export type ValidationResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; errors: AshRpcError[]; };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
86
assets/js/index.tsx
Normal file
86
assets/js/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { initLandingPage } from "./animation";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.getElementById("animation-container");
|
||||||
|
if (el) return initLandingPage(el);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base-100 text-base-content">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||||
|
<div className="flex items-center gap-5 mb-8">
|
||||||
|
<img
|
||||||
|
src="https://raw.githubusercontent.com/ash-project/ash_typescript/main/logos/ash-typescript.png"
|
||||||
|
alt="AshTypescript"
|
||||||
|
className="w-16 h-16"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold">AshTypescript</h1>
|
||||||
|
<p className="text-lg opacity-70">End-to-end type safety from Ash to TypeScript</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||||
|
<h2 className="text-2xl font-bold">Main Features</h2>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<a href="https://hexdocs.pm/ash_typescript" target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/ash-project/ash_typescript" target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<a href="https://hexdocs.pm/ash_typescript/first-rpc-action.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-base">Type-Safe RPC</h3>
|
||||||
|
<p className="text-sm opacity-70">Auto-generated typed functions for every Ash action.</p>
|
||||||
|
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://hexdocs.pm/ash_typescript/typed-controllers.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-base">Typed Controllers</h3>
|
||||||
|
<p className="text-sm opacity-70">Typed route helpers for Phoenix controllers.</p>
|
||||||
|
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://hexdocs.pm/ash_typescript/typed-channels.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-base">Typed Channels</h3>
|
||||||
|
<p className="text-sm opacity-70">Typed event subscriptions for Phoenix channels.</p>
|
||||||
|
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://hexdocs.pm/ash_typescript/form-validation.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-base">Zod Validation</h3>
|
||||||
|
<p className="text-sm opacity-70">Generated Zod schemas for form validation.</p>
|
||||||
|
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="animation-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("app")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
145
assets/package-lock.json
generated
Normal file
145
assets/package-lock.json
generated
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"phoenix": "file:../deps/phoenix",
|
||||||
|
"phoenix_html": "file:../deps/phoenix_html",
|
||||||
|
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"topbar": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../deps/phoenix": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/cli": "7.28.6",
|
||||||
|
"@babel/core": "7.29.0",
|
||||||
|
"@babel/preset-env": "7.29.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@stylistic/eslint-plugin": "^5.0.0",
|
||||||
|
"documentation": "^14.0.3",
|
||||||
|
"eslint": "10.0.2",
|
||||||
|
"eslint-plugin-jest": "29.15.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"jest-environment-jsdom": "^30.0.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"mock-socket": "^9.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../deps/phoenix_html": {
|
||||||
|
"version": "4.3.0"
|
||||||
|
},
|
||||||
|
"../deps/phoenix_live_view": {
|
||||||
|
"version": "1.1.28",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"morphdom": "2.7.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/cli": "7.27.2",
|
||||||
|
"@babel/core": "7.27.4",
|
||||||
|
"@babel/preset-env": "7.27.2",
|
||||||
|
"@babel/preset-typescript": "^7.27.1",
|
||||||
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"css.escape": "^1.5.1",
|
||||||
|
"eslint": "9.29.0",
|
||||||
|
"eslint-plugin-jest": "28.14.0",
|
||||||
|
"eslint-plugin-playwright": "^2.2.0",
|
||||||
|
"globals": "^16.2.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"jest-environment-jsdom": "^30.0.0",
|
||||||
|
"jest-monocart-coverage": "^1.1.1",
|
||||||
|
"monocart-reporter": "^2.9.21",
|
||||||
|
"phoenix": "1.7.21",
|
||||||
|
"prettier": "3.5.3",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.34.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-dom": {
|
||||||
|
"version": "19.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/phoenix": {
|
||||||
|
"resolved": "../deps/phoenix",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/phoenix_html": {
|
||||||
|
"resolved": "../deps/phoenix_html",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/phoenix_live_view": {
|
||||||
|
"resolved": "../deps/phoenix_live_view",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/topbar": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/topbar/-/topbar-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3CFWdkUC4KLsyLpW8IcX5EXskFtJtNl9O2/qNO/4Ncd/lWgipY/pmyv0D8bw2bTepl/+MUGhNhu2n3IitUuQNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
assets/package.json
Normal file
14
assets/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"phoenix": "file:../deps/phoenix",
|
||||||
|
"phoenix_html": "file:../deps/phoenix_html",
|
||||||
|
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"topbar": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
assets/tsconfig.json
Normal file
34
assets/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// This file is needed on most editors to enable the intelligent autocompletion
|
||||||
|
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
|
||||||
|
//
|
||||||
|
// Note: This file assumes a basic esbuild setup without node_modules.
|
||||||
|
// We include a generic paths alias to deps to mimic how esbuild resolves
|
||||||
|
// the Phoenix and LiveView JavaScript assets.
|
||||||
|
// If you have a package.json in your project, you should remove the
|
||||||
|
// paths configuration and instead add the phoenix dependencies to the
|
||||||
|
// dependencies section of your package.json:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// ...
|
||||||
|
// "dependencies": {
|
||||||
|
// ...,
|
||||||
|
// "phoenix": "../deps/phoenix",
|
||||||
|
// "phoenix_html": "../deps/phoenix_html",
|
||||||
|
// "phoenix_live_view": "../deps/phoenix_live_view"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Feel free to adjust this configuration however you need.
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"*": ["../deps/*"]
|
||||||
|
},
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["js/**/*"]
|
||||||
|
}
|
||||||
124
assets/vendor/daisyui-theme.js
vendored
Normal file
124
assets/vendor/daisyui-theme.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1031
assets/vendor/daisyui.js
vendored
Normal file
1031
assets/vendor/daisyui.js
vendored
Normal file
File diff suppressed because one or more lines are too long
43
assets/vendor/heroicons.js
vendored
Normal file
43
assets/vendor/heroicons.js
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const plugin = require("tailwindcss/plugin")
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
module.exports = plugin(function({matchComponents, theme}) {
|
||||||
|
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
|
||||||
|
let values = {}
|
||||||
|
let icons = [
|
||||||
|
["", "/24/outline"],
|
||||||
|
["-solid", "/24/solid"],
|
||||||
|
["-mini", "/20/solid"],
|
||||||
|
["-micro", "/16/solid"]
|
||||||
|
]
|
||||||
|
icons.forEach(([suffix, dir]) => {
|
||||||
|
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||||
|
let name = path.basename(file, ".svg") + suffix
|
||||||
|
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
matchComponents({
|
||||||
|
"hero": ({name, fullPath}) => {
|
||||||
|
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||||
|
content = encodeURIComponent(content)
|
||||||
|
let size = theme("spacing.6")
|
||||||
|
if (name.endsWith("-mini")) {
|
||||||
|
size = theme("spacing.5")
|
||||||
|
} else if (name.endsWith("-micro")) {
|
||||||
|
size = theme("spacing.4")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||||
|
"-webkit-mask": `var(--hero-${name})`,
|
||||||
|
"mask": `var(--hero-${name})`,
|
||||||
|
"mask-repeat": "no-repeat",
|
||||||
|
"background-color": "currentColor",
|
||||||
|
"vertical-align": "middle",
|
||||||
|
"display": "inline-block",
|
||||||
|
"width": size,
|
||||||
|
"height": size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {values})
|
||||||
|
})
|
||||||
147
config/config.exs
Normal file
147
config/config.exs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# This file is responsible for configuring your application
|
||||||
|
# and its dependencies with the aid of the Config module.
|
||||||
|
#
|
||||||
|
# This configuration file is loaded before any dependency and
|
||||||
|
# is restricted to this project.
|
||||||
|
|
||||||
|
# General application configuration
|
||||||
|
import Config
|
||||||
|
|
||||||
|
config :ash_typescript,
|
||||||
|
output_file: "assets/js/ash_rpc.ts",
|
||||||
|
run_endpoint: "/rpc/run",
|
||||||
|
validate_endpoint: "/rpc/validate",
|
||||||
|
input_field_formatter: :camel_case,
|
||||||
|
output_field_formatter: :camel_case,
|
||||||
|
require_tenant_parameters: false,
|
||||||
|
generate_zod_schemas: false,
|
||||||
|
generate_phx_channel_rpc_actions: false,
|
||||||
|
generate_validation_functions: true,
|
||||||
|
zod_import_path: "zod",
|
||||||
|
zod_schema_suffix: "ZodSchema",
|
||||||
|
phoenix_import_path: "phoenix"
|
||||||
|
|
||||||
|
config :mime,
|
||||||
|
extensions: %{"json" => "application/vnd.api+json"},
|
||||||
|
types: %{"application/vnd.api+json" => ["json"]}
|
||||||
|
|
||||||
|
config :ash_json_api,
|
||||||
|
show_public_calculations_when_loaded?: false,
|
||||||
|
authorize_update_destroy_with_error?: true
|
||||||
|
|
||||||
|
config :ash_graphql, authorize_update_destroy_with_error?: true
|
||||||
|
|
||||||
|
config :ash,
|
||||||
|
allow_forbidden_field_for_relationships_by_default?: true,
|
||||||
|
include_embedded_source_by_default?: false,
|
||||||
|
show_keysets_for_all_actions?: false,
|
||||||
|
default_page_type: :keyset,
|
||||||
|
policies: [no_filter_static_forbidden_reads?: false],
|
||||||
|
keep_read_action_loads_when_loading?: false,
|
||||||
|
default_actions_require_atomic?: true,
|
||||||
|
read_action_after_action_hooks_in_order?: true,
|
||||||
|
bulk_actions_default_to_errors?: true,
|
||||||
|
transaction_rollback_on_error?: true,
|
||||||
|
redact_sensitive_values_in_errors?: true,
|
||||||
|
known_types: [AshPostgres.Timestamptz, AshPostgres.TimestamptzUsec]
|
||||||
|
|
||||||
|
config :spark,
|
||||||
|
formatter: [
|
||||||
|
remove_parens?: true,
|
||||||
|
"Ash.Resource": [
|
||||||
|
section_order: [
|
||||||
|
:admin,
|
||||||
|
:authentication,
|
||||||
|
:token,
|
||||||
|
:user_identity,
|
||||||
|
:postgres,
|
||||||
|
:json_api,
|
||||||
|
:graphql,
|
||||||
|
:resource,
|
||||||
|
:code_interface,
|
||||||
|
:actions,
|
||||||
|
:policies,
|
||||||
|
:pub_sub,
|
||||||
|
:preparations,
|
||||||
|
:changes,
|
||||||
|
:validations,
|
||||||
|
:multitenancy,
|
||||||
|
:attributes,
|
||||||
|
:relationships,
|
||||||
|
:calculations,
|
||||||
|
:aggregates,
|
||||||
|
:identities
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"Ash.Domain": [
|
||||||
|
section_order: [
|
||||||
|
:admin,
|
||||||
|
:json_api,
|
||||||
|
:graphql,
|
||||||
|
:resources,
|
||||||
|
:policies,
|
||||||
|
:authorization,
|
||||||
|
:domain,
|
||||||
|
:execution
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
config :mixer,
|
||||||
|
ecto_repos: [Mixer.Repo],
|
||||||
|
generators: [timestamp_type: :utc_datetime],
|
||||||
|
ash_domains: [Mixer.Accounts],
|
||||||
|
ash_authentication: [return_error_on_invalid_magic_link_token?: true]
|
||||||
|
|
||||||
|
# Configure the endpoint
|
||||||
|
config :mixer, MixerWeb.Endpoint,
|
||||||
|
url: [host: "localhost"],
|
||||||
|
adapter: Bandit.PhoenixAdapter,
|
||||||
|
render_errors: [
|
||||||
|
formats: [html: MixerWeb.ErrorHTML, json: MixerWeb.ErrorJSON],
|
||||||
|
layout: false
|
||||||
|
],
|
||||||
|
pubsub_server: Mixer.PubSub,
|
||||||
|
live_view: [signing_salt: "tZ/Kgc7g"]
|
||||||
|
|
||||||
|
# Configure the mailer
|
||||||
|
#
|
||||||
|
# By default it uses the "Local" adapter which stores the emails
|
||||||
|
# locally. You can see the emails in your browser, at "/dev/mailbox".
|
||||||
|
#
|
||||||
|
# For production it's recommended to configure a different adapter
|
||||||
|
# at the `config/runtime.exs`.
|
||||||
|
config :mixer, Mixer.Mailer, adapter: Swoosh.Adapters.Local
|
||||||
|
|
||||||
|
# Configure esbuild (the version is required)
|
||||||
|
config :esbuild,
|
||||||
|
version: "0.25.4",
|
||||||
|
mixer: [
|
||||||
|
args:
|
||||||
|
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm),
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Configure tailwind (the version is required)
|
||||||
|
config :tailwind,
|
||||||
|
version: "4.1.12",
|
||||||
|
mixer: [
|
||||||
|
args: ~w(
|
||||||
|
--input=assets/css/app.css
|
||||||
|
--output=priv/static/assets/css/app.css
|
||||||
|
),
|
||||||
|
cd: Path.expand("..", __DIR__)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Configure Elixir's Logger
|
||||||
|
config :logger, :default_formatter,
|
||||||
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
metadata: [:request_id]
|
||||||
|
|
||||||
|
# Use Jason for JSON parsing in Phoenix
|
||||||
|
config :phoenix, :json_library, Jason
|
||||||
|
|
||||||
|
# Import environment specific config. This must remain at the bottom
|
||||||
|
# of this file so it overrides the configuration defined above.
|
||||||
|
import_config "#{config_env()}.exs"
|
||||||
93
config/dev.exs
Normal file
93
config/dev.exs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Config
|
||||||
|
config :ash, policies: [show_policy_breakdowns?: true]
|
||||||
|
|
||||||
|
# Configure your database
|
||||||
|
config :mixer, Mixer.Repo,
|
||||||
|
username: "postgres",
|
||||||
|
password: "postgres",
|
||||||
|
hostname: "localhost",
|
||||||
|
database: "mixer_dev",
|
||||||
|
stacktrace: true,
|
||||||
|
show_sensitive_data_on_connection_error: true,
|
||||||
|
pool_size: 10
|
||||||
|
|
||||||
|
# For development, we disable any cache and enable
|
||||||
|
# debugging and code reloading.
|
||||||
|
#
|
||||||
|
# The watchers configuration can be used to run external
|
||||||
|
# watchers to your application. For example, we can use it
|
||||||
|
# to bundle .js and .css sources.
|
||||||
|
config :mixer, MixerWeb.Endpoint,
|
||||||
|
# Binding to loopback ipv4 address prevents access from other machines.
|
||||||
|
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||||
|
http: [ip: {127, 0, 0, 1}],
|
||||||
|
check_origin: false,
|
||||||
|
code_reloader: true,
|
||||||
|
debug_errors: true,
|
||||||
|
secret_key_base: "FF4RUQclUY5sxr0EzZh1BbGROJ9u1gqlqZejlwd0OaGkZ9IXXTBvKZyTVVnzqyyQ",
|
||||||
|
watchers: [
|
||||||
|
esbuild: {Esbuild, :install_and_run, [:mixer, ~w(--sourcemap=inline --watch)]},
|
||||||
|
tailwind: {Tailwind, :install_and_run, [:mixer, ~w(--watch)]}
|
||||||
|
]
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# In order to use HTTPS in development, a self-signed
|
||||||
|
# certificate can be generated by running the following
|
||||||
|
# Mix task:
|
||||||
|
#
|
||||||
|
# mix phx.gen.cert
|
||||||
|
#
|
||||||
|
# Run `mix help phx.gen.cert` for more information.
|
||||||
|
#
|
||||||
|
# The `http:` config above can be replaced with:
|
||||||
|
#
|
||||||
|
# https: [
|
||||||
|
# port: 4001,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||||
|
# certfile: "priv/cert/selfsigned.pem"
|
||||||
|
# ],
|
||||||
|
#
|
||||||
|
# If desired, both `http:` and `https:` keys can be
|
||||||
|
# configured to run both http and https servers on
|
||||||
|
# different ports.
|
||||||
|
|
||||||
|
# Reload browser tabs when matching files change.
|
||||||
|
config :mixer, MixerWeb.Endpoint,
|
||||||
|
live_reload: [
|
||||||
|
web_console_logger: true,
|
||||||
|
patterns: [
|
||||||
|
# Static assets, except user uploads
|
||||||
|
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E,
|
||||||
|
# Gettext translations
|
||||||
|
~r"priv/gettext/.*\.po$"E,
|
||||||
|
# Router, Controllers, LiveViews and LiveComponents
|
||||||
|
~r"lib/mixer_web/router\.ex$"E,
|
||||||
|
~r"lib/mixer_web/(controllers|live|components)/.*\.(ex|heex)$"E
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Enable dev routes for dashboard and mailbox
|
||||||
|
config :mixer, dev_routes: true, token_signing_secret: "J06wgyCjmf+zScGeTvMsBcNONLL4Bw4Y"
|
||||||
|
|
||||||
|
# Do not include metadata nor timestamps in development logs
|
||||||
|
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||||
|
|
||||||
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
|
# in production as building large stacktraces may be expensive.
|
||||||
|
config :phoenix, :stacktrace_depth, 20
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster development compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
|
config :phoenix_live_view,
|
||||||
|
# Include debug annotations and locations in rendered markup.
|
||||||
|
# Changing this configuration will require mix clean and a full recompile.
|
||||||
|
debug_heex_annotations: true,
|
||||||
|
debug_attributes: true,
|
||||||
|
# Enable helpful, but potentially expensive runtime checks
|
||||||
|
enable_expensive_runtime_checks: true
|
||||||
|
|
||||||
|
# Disable swoosh api client as it is only required for production adapters.
|
||||||
|
config :swoosh, :api_client, false
|
||||||
32
config/prod.exs
Normal file
32
config/prod.exs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# Note we also include the path to a cache manifest
|
||||||
|
# containing the digested version of static files. This
|
||||||
|
# manifest is generated by the `mix assets.deploy` task,
|
||||||
|
# which you should run after static files are built and
|
||||||
|
# before starting your production server.
|
||||||
|
config :mixer, MixerWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||||
|
|
||||||
|
# Force using SSL in production. This also sets the "strict-security-transport" header,
|
||||||
|
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
|
||||||
|
# Note `:force_ssl` is required to be set at compile-time.
|
||||||
|
config :mixer, MixerWeb.Endpoint,
|
||||||
|
force_ssl: [
|
||||||
|
rewrite_on: [:x_forwarded_proto],
|
||||||
|
exclude: [
|
||||||
|
# paths: ["/health"],
|
||||||
|
hosts: ["localhost", "127.0.0.1"]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Configure Swoosh API Client
|
||||||
|
config :swoosh, api_client: Swoosh.ApiClient.Req
|
||||||
|
|
||||||
|
# Disable Swoosh Local Memory Storage
|
||||||
|
config :swoosh, local: false
|
||||||
|
|
||||||
|
# Do not print debug messages in production
|
||||||
|
config :logger, level: :info
|
||||||
|
|
||||||
|
# Runtime production configuration, including reading
|
||||||
|
# of environment variables, is done on config/runtime.exs.
|
||||||
124
config/runtime.exs
Normal file
124
config/runtime.exs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# config/runtime.exs is executed for all environments, including
|
||||||
|
# during releases. It is executed after compilation and before the
|
||||||
|
# system starts, so it is typically used to load production configuration
|
||||||
|
# and secrets from environment variables or elsewhere. Do not define
|
||||||
|
# any compile-time configuration in here, as it won't be applied.
|
||||||
|
# The block below contains prod specific runtime configuration.
|
||||||
|
|
||||||
|
# ## Using releases
|
||||||
|
#
|
||||||
|
# If you use `mix release`, you need to explicitly enable the server
|
||||||
|
# by passing the PHX_SERVER=true when you start it:
|
||||||
|
#
|
||||||
|
# PHX_SERVER=true bin/mixer start
|
||||||
|
#
|
||||||
|
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
|
||||||
|
# script that automatically sets the env var above.
|
||||||
|
if System.get_env("PHX_SERVER") do
|
||||||
|
config :mixer, MixerWeb.Endpoint, server: true
|
||||||
|
end
|
||||||
|
|
||||||
|
config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
|
||||||
|
|
||||||
|
if config_env() == :prod do
|
||||||
|
database_url =
|
||||||
|
System.get_env("DATABASE_URL") ||
|
||||||
|
raise """
|
||||||
|
environment variable DATABASE_URL is missing.
|
||||||
|
For example: ecto://USER:PASS@HOST/DATABASE
|
||||||
|
"""
|
||||||
|
|
||||||
|
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||||
|
|
||||||
|
config :mixer, Mixer.Repo,
|
||||||
|
# ssl: true,
|
||||||
|
url: database_url,
|
||||||
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||||
|
# For machines with several cores, consider starting multiple pools of `pool_size`
|
||||||
|
# pool_count: 4,
|
||||||
|
socket_options: maybe_ipv6
|
||||||
|
|
||||||
|
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||||
|
# A default value is used in config/dev.exs and config/test.exs but you
|
||||||
|
# want to use a different value for prod and you most likely don't want
|
||||||
|
# to check this value into version control, so we use an environment
|
||||||
|
# variable instead.
|
||||||
|
secret_key_base =
|
||||||
|
System.get_env("SECRET_KEY_BASE") ||
|
||||||
|
raise """
|
||||||
|
environment variable SECRET_KEY_BASE is missing.
|
||||||
|
You can generate one by calling: mix phx.gen.secret
|
||||||
|
"""
|
||||||
|
|
||||||
|
host = System.get_env("PHX_HOST") || "example.com"
|
||||||
|
|
||||||
|
config :mixer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||||
|
|
||||||
|
config :mixer, MixerWeb.Endpoint,
|
||||||
|
url: [host: host, port: 443, scheme: "https"],
|
||||||
|
http: [
|
||||||
|
# Enable IPv6 and bind on all interfaces.
|
||||||
|
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||||
|
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||||
|
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||||
|
ip: {0, 0, 0, 0, 0, 0, 0, 0}
|
||||||
|
],
|
||||||
|
secret_key_base: secret_key_base
|
||||||
|
|
||||||
|
config :mixer,
|
||||||
|
token_signing_secret:
|
||||||
|
System.get_env("TOKEN_SIGNING_SECRET") ||
|
||||||
|
raise("Missing environment variable `TOKEN_SIGNING_SECRET`!")
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# To get SSL working, you will need to add the `https` key
|
||||||
|
# to your endpoint configuration:
|
||||||
|
#
|
||||||
|
# config :mixer, MixerWeb.Endpoint,
|
||||||
|
# https: [
|
||||||
|
# ...,
|
||||||
|
# port: 443,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||||
|
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# The `cipher_suite` is set to `:strong` to support only the
|
||||||
|
# latest and more secure SSL ciphers. This means old browsers
|
||||||
|
# and clients may not be supported. You can set it to
|
||||||
|
# `:compatible` for wider support.
|
||||||
|
#
|
||||||
|
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||||
|
# and cert in disk or a relative path inside priv, for example
|
||||||
|
# "priv/ssl/server.key". For all supported SSL configuration
|
||||||
|
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||||
|
#
|
||||||
|
# We also recommend setting `force_ssl` in your config/prod.exs,
|
||||||
|
# ensuring no data is ever sent via http, always redirecting to https:
|
||||||
|
#
|
||||||
|
# config :mixer, MixerWeb.Endpoint,
|
||||||
|
# force_ssl: [hsts: true]
|
||||||
|
#
|
||||||
|
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||||
|
|
||||||
|
# ## Configuring the mailer
|
||||||
|
#
|
||||||
|
# In production you need to configure the mailer to use a different adapter.
|
||||||
|
# Here is an example configuration for Mailgun:
|
||||||
|
#
|
||||||
|
# config :mixer, Mixer.Mailer,
|
||||||
|
# adapter: Swoosh.Adapters.Mailgun,
|
||||||
|
# api_key: System.get_env("MAILGUN_API_KEY"),
|
||||||
|
# domain: System.get_env("MAILGUN_DOMAIN")
|
||||||
|
#
|
||||||
|
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
|
||||||
|
# and Finch out-of-the-box. This configuration is typically done at
|
||||||
|
# compile-time in your config/prod.exs:
|
||||||
|
#
|
||||||
|
# config :swoosh, :api_client, Swoosh.ApiClient.Req
|
||||||
|
#
|
||||||
|
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||||
|
end
|
||||||
44
config/test.exs
Normal file
44
config/test.exs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Config
|
||||||
|
config :mixer, token_signing_secret: "taN7djLR1LSoRCOhGjKO6W/HbfjiBV4g"
|
||||||
|
config :bcrypt_elixir, log_rounds: 1
|
||||||
|
config :ash, policies: [show_policy_breakdowns?: true], disable_async?: true
|
||||||
|
|
||||||
|
# Configure your database
|
||||||
|
#
|
||||||
|
# The MIX_TEST_PARTITION environment variable can be used
|
||||||
|
# to provide built-in test partitioning in CI environment.
|
||||||
|
# Run `mix help test` for more information.
|
||||||
|
config :mixer, Mixer.Repo,
|
||||||
|
username: "postgres",
|
||||||
|
password: "postgres",
|
||||||
|
hostname: "localhost",
|
||||||
|
database: "mixer_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||||
|
pool: Ecto.Adapters.SQL.Sandbox,
|
||||||
|
pool_size: System.schedulers_online() * 2
|
||||||
|
|
||||||
|
# We don't run a server during test. If one is required,
|
||||||
|
# you can enable the server option below.
|
||||||
|
config :mixer, MixerWeb.Endpoint,
|
||||||
|
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||||
|
secret_key_base: "86MVv/OM209I0ejDXaEIu9EuHo43DW7W3ZudteeKsr1W2pP80bRxAssgmQLBlt+Q",
|
||||||
|
server: false
|
||||||
|
|
||||||
|
# In test we don't send emails
|
||||||
|
config :mixer, Mixer.Mailer, adapter: Swoosh.Adapters.Test
|
||||||
|
|
||||||
|
# Disable swoosh api client as it is only required for production adapters
|
||||||
|
config :swoosh, :api_client, false
|
||||||
|
|
||||||
|
# Print only warnings and errors during test
|
||||||
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster test compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
|
# Enable helpful, but potentially expensive runtime checks
|
||||||
|
config :phoenix_live_view,
|
||||||
|
enable_expensive_runtime_checks: true
|
||||||
|
|
||||||
|
# Sort query params output of verified routes for robust url comparisons
|
||||||
|
config :phoenix,
|
||||||
|
sort_verified_routes_query_params: true
|
||||||
9
lib/mixer.ex
Normal file
9
lib/mixer.ex
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Mixer do
|
||||||
|
@moduledoc """
|
||||||
|
Mixer keeps the contexts that define your domain
|
||||||
|
and business logic.
|
||||||
|
|
||||||
|
Contexts are also responsible for managing your data, regardless
|
||||||
|
if it comes from the database, an external API or others.
|
||||||
|
"""
|
||||||
|
end
|
||||||
13
lib/mixer/accounts.ex
Normal file
13
lib/mixer/accounts.ex
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule Mixer.Accounts do
|
||||||
|
use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain]
|
||||||
|
|
||||||
|
admin do
|
||||||
|
show? true
|
||||||
|
end
|
||||||
|
|
||||||
|
resources do
|
||||||
|
resource Mixer.Accounts.Token
|
||||||
|
resource Mixer.Accounts.User
|
||||||
|
resource Mixer.Accounts.ApiKey
|
||||||
|
end
|
||||||
|
end
|
||||||
55
lib/mixer/accounts/api_key.ex
Normal file
55
lib/mixer/accounts/api_key.ex
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
defmodule Mixer.Accounts.ApiKey do
|
||||||
|
use Ash.Resource,
|
||||||
|
otp_app: :mixer,
|
||||||
|
domain: Mixer.Accounts,
|
||||||
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "api_keys"
|
||||||
|
repo Mixer.Repo
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:read, :destroy]
|
||||||
|
|
||||||
|
create :create do
|
||||||
|
primary? true
|
||||||
|
accept [:user_id, :expires_at]
|
||||||
|
|
||||||
|
change {AshAuthentication.Strategy.ApiKey.GenerateApiKey,
|
||||||
|
prefix: :mixer, hash: :api_key_hash}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_primary_key :id
|
||||||
|
|
||||||
|
attribute :api_key_hash, :binary do
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :expires_at, :utc_datetime_usec do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
belongs_to :user, Mixer.Accounts.User
|
||||||
|
end
|
||||||
|
|
||||||
|
calculations do
|
||||||
|
calculate :valid, :boolean, expr(expires_at > now())
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_api_key, [:api_key_hash]
|
||||||
|
end
|
||||||
|
end
|
||||||
114
lib/mixer/accounts/token.ex
Normal file
114
lib/mixer/accounts/token.ex
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
defmodule Mixer.Accounts.Token do
|
||||||
|
use Ash.Resource,
|
||||||
|
otp_app: :mixer,
|
||||||
|
domain: Mixer.Accounts,
|
||||||
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer],
|
||||||
|
extensions: [AshAuthentication.TokenResource]
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "tokens"
|
||||||
|
repo Mixer.Repo
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:read]
|
||||||
|
|
||||||
|
read :expired do
|
||||||
|
description "Look up all expired tokens."
|
||||||
|
filter expr(expires_at < now())
|
||||||
|
end
|
||||||
|
|
||||||
|
read :get_token do
|
||||||
|
description "Look up a token by JTI or token, and an optional purpose."
|
||||||
|
get? true
|
||||||
|
argument :token, :string, sensitive?: true
|
||||||
|
argument :jti, :string, sensitive?: true
|
||||||
|
argument :purpose, :string, sensitive?: false
|
||||||
|
|
||||||
|
prepare AshAuthentication.TokenResource.GetTokenPreparation
|
||||||
|
end
|
||||||
|
|
||||||
|
action :revoked?, :boolean do
|
||||||
|
description "Returns true if a revocation token is found for the provided token"
|
||||||
|
argument :token, :string, sensitive?: true
|
||||||
|
argument :jti, :string, sensitive?: true
|
||||||
|
|
||||||
|
run AshAuthentication.TokenResource.IsRevoked
|
||||||
|
end
|
||||||
|
|
||||||
|
create :revoke_token do
|
||||||
|
description "Revoke a token. Creates a revocation token corresponding to the provided token."
|
||||||
|
accept [:extra_data]
|
||||||
|
argument :token, :string, allow_nil?: false, sensitive?: true
|
||||||
|
|
||||||
|
change AshAuthentication.TokenResource.RevokeTokenChange
|
||||||
|
end
|
||||||
|
|
||||||
|
create :revoke_jti do
|
||||||
|
description "Revoke a token by JTI. Creates a revocation token corresponding to the provided jti."
|
||||||
|
accept [:extra_data]
|
||||||
|
argument :subject, :string, allow_nil?: false, sensitive?: true
|
||||||
|
argument :jti, :string, allow_nil?: false, sensitive?: true
|
||||||
|
|
||||||
|
change AshAuthentication.TokenResource.RevokeJtiChange
|
||||||
|
end
|
||||||
|
|
||||||
|
create :store_token do
|
||||||
|
description "Stores a token used for the provided purpose."
|
||||||
|
accept [:extra_data, :purpose]
|
||||||
|
argument :token, :string, allow_nil?: false, sensitive?: true
|
||||||
|
change AshAuthentication.TokenResource.StoreTokenChange
|
||||||
|
end
|
||||||
|
|
||||||
|
destroy :expunge_expired do
|
||||||
|
description "Deletes expired tokens."
|
||||||
|
change filter(expr(expires_at < now()))
|
||||||
|
end
|
||||||
|
|
||||||
|
update :revoke_all_stored_for_subject do
|
||||||
|
description "Revokes all stored tokens for a specific subject."
|
||||||
|
accept [:extra_data]
|
||||||
|
argument :subject, :string, allow_nil?: false, sensitive?: true
|
||||||
|
change AshAuthentication.TokenResource.RevokeAllStoredForSubjectChange
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
|
description "AshAuthentication can interact with the token resource"
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
attribute :jti, :string do
|
||||||
|
primary_key? true
|
||||||
|
public? true
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :subject, :string do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :expires_at, :utc_datetime do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :purpose, :string do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :extra_data, :map do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_timestamp :created_at
|
||||||
|
update_timestamp :updated_at
|
||||||
|
end
|
||||||
|
end
|
||||||
311
lib/mixer/accounts/user.ex
Normal file
311
lib/mixer/accounts/user.ex
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
defmodule Mixer.Accounts.User do
|
||||||
|
use Ash.Resource,
|
||||||
|
otp_app: :mixer,
|
||||||
|
domain: Mixer.Accounts,
|
||||||
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer],
|
||||||
|
extensions: [AshAuthentication]
|
||||||
|
|
||||||
|
authentication do
|
||||||
|
add_ons do
|
||||||
|
log_out_everywhere do
|
||||||
|
apply_on_password_change? true
|
||||||
|
end
|
||||||
|
|
||||||
|
confirmation :confirm_new_user do
|
||||||
|
monitor_fields [:email]
|
||||||
|
confirm_on_create? true
|
||||||
|
confirm_on_update? false
|
||||||
|
require_interaction? true
|
||||||
|
confirmed_at_field :confirmed_at
|
||||||
|
auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token]
|
||||||
|
sender Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tokens do
|
||||||
|
enabled? true
|
||||||
|
token_resource Mixer.Accounts.Token
|
||||||
|
signing_secret Mixer.Secrets
|
||||||
|
store_all_tokens? true
|
||||||
|
require_token_presence_for_authentication? true
|
||||||
|
end
|
||||||
|
|
||||||
|
strategies do
|
||||||
|
password :password do
|
||||||
|
identity_field :email
|
||||||
|
hash_provider AshAuthentication.BcryptProvider
|
||||||
|
|
||||||
|
resettable do
|
||||||
|
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
|
||||||
|
# these configurations will be the default in a future release
|
||||||
|
password_reset_action_name :reset_password_with_token
|
||||||
|
request_password_reset_action_name :request_password_reset_token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
remember_me :remember_me
|
||||||
|
|
||||||
|
magic_link do
|
||||||
|
identity_field :email
|
||||||
|
registration_enabled? true
|
||||||
|
require_interaction? true
|
||||||
|
|
||||||
|
sender Mixer.Accounts.User.Senders.SendMagicLinkEmail
|
||||||
|
end
|
||||||
|
|
||||||
|
api_key :api_key do
|
||||||
|
api_key_relationship :valid_api_keys
|
||||||
|
api_key_hash_attribute :api_key_hash
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "users"
|
||||||
|
repo Mixer.Repo
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:read]
|
||||||
|
|
||||||
|
read :get_by_subject do
|
||||||
|
description "Get a user by the subject claim in a JWT"
|
||||||
|
argument :subject, :string, allow_nil?: false
|
||||||
|
get? true
|
||||||
|
prepare AshAuthentication.Preparations.FilterBySubject
|
||||||
|
end
|
||||||
|
|
||||||
|
update :change_password do
|
||||||
|
# Use this action to allow users to change their password by providing
|
||||||
|
# their current password and a new password.
|
||||||
|
|
||||||
|
require_atomic? false
|
||||||
|
accept []
|
||||||
|
argument :current_password, :string, sensitive?: true, allow_nil?: false
|
||||||
|
|
||||||
|
argument :password, :string,
|
||||||
|
sensitive?: true,
|
||||||
|
allow_nil?: false,
|
||||||
|
constraints: [min_length: 8]
|
||||||
|
|
||||||
|
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
|
||||||
|
|
||||||
|
validate confirm(:password, :password_confirmation)
|
||||||
|
|
||||||
|
validate {AshAuthentication.Strategy.Password.PasswordValidation,
|
||||||
|
strategy_name: :password, password_argument: :current_password}
|
||||||
|
|
||||||
|
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
|
||||||
|
end
|
||||||
|
|
||||||
|
read :sign_in_with_password do
|
||||||
|
description "Attempt to sign in using a email and password."
|
||||||
|
get? true
|
||||||
|
|
||||||
|
argument :email, :ci_string do
|
||||||
|
description "The email to use for retrieving the user."
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
argument :password, :string do
|
||||||
|
description "The password to check for the matching user."
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
# validates the provided email and password and generates a token
|
||||||
|
prepare AshAuthentication.Strategy.Password.SignInPreparation
|
||||||
|
|
||||||
|
metadata :token, :string do
|
||||||
|
description "A JWT that can be used to authenticate the user."
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
read :sign_in_with_token do
|
||||||
|
# In the generated sign in components, we validate the
|
||||||
|
# email and password directly in the LiveView
|
||||||
|
# and generate a short-lived token that can be used to sign in over
|
||||||
|
# a standard controller action, exchanging it for a standard token.
|
||||||
|
# This action performs that exchange. If you do not use the generated
|
||||||
|
# liveviews, you may remove this action, and set
|
||||||
|
# `sign_in_tokens_enabled? false` in the password strategy.
|
||||||
|
|
||||||
|
description "Attempt to sign in using a short-lived sign in token."
|
||||||
|
get? true
|
||||||
|
|
||||||
|
argument :token, :string do
|
||||||
|
description "The short-lived sign in token."
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
# validates the provided sign in token and generates a token
|
||||||
|
prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation
|
||||||
|
|
||||||
|
metadata :token, :string do
|
||||||
|
description "A JWT that can be used to authenticate the user."
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
create :register_with_password do
|
||||||
|
description "Register a new user with a email and password."
|
||||||
|
|
||||||
|
argument :email, :ci_string do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
argument :password, :string do
|
||||||
|
description "The proposed password for the user, in plain text."
|
||||||
|
allow_nil? false
|
||||||
|
constraints min_length: 8
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
argument :password_confirmation, :string do
|
||||||
|
description "The proposed password for the user (again), in plain text."
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sets the email from the argument
|
||||||
|
change set_attribute(:email, arg(:email))
|
||||||
|
|
||||||
|
# Hashes the provided password
|
||||||
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||||
|
|
||||||
|
# Generates an authentication token for the user
|
||||||
|
change AshAuthentication.GenerateTokenChange
|
||||||
|
|
||||||
|
# validates that the password matches the confirmation
|
||||||
|
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
|
||||||
|
|
||||||
|
metadata :token, :string do
|
||||||
|
description "A JWT that can be used to authenticate the user."
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
action :request_password_reset_token do
|
||||||
|
description "Send password reset instructions to a user if they exist."
|
||||||
|
|
||||||
|
argument :email, :ci_string do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
# creates a reset token and invokes the relevant senders
|
||||||
|
run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_email}
|
||||||
|
end
|
||||||
|
|
||||||
|
read :get_by_email do
|
||||||
|
description "Looks up a user by their email"
|
||||||
|
get_by :email
|
||||||
|
end
|
||||||
|
|
||||||
|
update :reset_password_with_token do
|
||||||
|
argument :reset_token, :string do
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
argument :password, :string do
|
||||||
|
description "The proposed password for the user, in plain text."
|
||||||
|
allow_nil? false
|
||||||
|
constraints min_length: 8
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
argument :password_confirmation, :string do
|
||||||
|
description "The proposed password for the user (again), in plain text."
|
||||||
|
allow_nil? false
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
# validates the provided reset token
|
||||||
|
validate AshAuthentication.Strategy.Password.ResetTokenValidation
|
||||||
|
|
||||||
|
# validates that the password matches the confirmation
|
||||||
|
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
|
||||||
|
|
||||||
|
# Hashes the provided password
|
||||||
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||||
|
|
||||||
|
# Generates an authentication token for the user
|
||||||
|
change AshAuthentication.GenerateTokenChange
|
||||||
|
end
|
||||||
|
|
||||||
|
create :sign_in_with_magic_link do
|
||||||
|
description "Sign in or register a user with magic link."
|
||||||
|
|
||||||
|
argument :token, :string do
|
||||||
|
description "The token from the magic link that was sent to the user"
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
argument :remember_me, :boolean do
|
||||||
|
description "Whether to generate a remember me token"
|
||||||
|
allow_nil? true
|
||||||
|
end
|
||||||
|
|
||||||
|
upsert? true
|
||||||
|
upsert_identity :unique_email
|
||||||
|
upsert_fields [:email]
|
||||||
|
|
||||||
|
# Uses the information from the token to create or sign in the user
|
||||||
|
change AshAuthentication.Strategy.MagicLink.SignInChange
|
||||||
|
|
||||||
|
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
|
||||||
|
strategy_name: :remember_me}
|
||||||
|
|
||||||
|
metadata :token, :string do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
action :request_magic_link do
|
||||||
|
argument :email, :ci_string do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
run AshAuthentication.Strategy.MagicLink.Request
|
||||||
|
end
|
||||||
|
|
||||||
|
read :sign_in_with_api_key do
|
||||||
|
argument :api_key, :string, allow_nil?: false
|
||||||
|
prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_primary_key :id
|
||||||
|
|
||||||
|
attribute :email, :ci_string do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :hashed_password, :string do
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :confirmed_at, :utc_datetime_usec
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
has_many :valid_api_keys, Mixer.Accounts.ApiKey do
|
||||||
|
filter expr(valid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_email, [:email]
|
||||||
|
end
|
||||||
|
end
|
||||||
40
lib/mixer/accounts/user/senders/send_magic_link_email.ex
Normal file
40
lib/mixer/accounts/user/senders/send_magic_link_email.ex
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
|
||||||
|
@moduledoc """
|
||||||
|
Sends a magic link email
|
||||||
|
"""
|
||||||
|
|
||||||
|
use AshAuthentication.Sender
|
||||||
|
use MixerWeb, :verified_routes
|
||||||
|
|
||||||
|
import Swoosh.Email
|
||||||
|
alias Mixer.Mailer
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def send(user_or_email, token, _) do
|
||||||
|
# if you get a user, its for a user that already exists.
|
||||||
|
# if you get an email, then the user does not yet exist.
|
||||||
|
|
||||||
|
email =
|
||||||
|
case user_or_email do
|
||||||
|
%{email: email} -> email
|
||||||
|
email -> email
|
||||||
|
end
|
||||||
|
|
||||||
|
new()
|
||||||
|
# TODO: Replace with your email
|
||||||
|
|> from({"noreply", "noreply@example.com"})
|
||||||
|
|> to(to_string(email))
|
||||||
|
|> subject("Your login link")
|
||||||
|
|> html_body(body(token: token, email: email))
|
||||||
|
|> Mailer.deliver!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp body(params) do
|
||||||
|
# NOTE: You may have to change this to match your magic link acceptance URL.
|
||||||
|
|
||||||
|
"""
|
||||||
|
<p>Hello, #{params[:email]}! Click this link to sign in:</p>
|
||||||
|
<p><a href="#{url(~p"/magic_link/#{params[:token]}")}">#{url(~p"/magic_link/#{params[:token]}")}</a></p>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
|
@moduledoc """
|
||||||
|
Sends an email for a new user to confirm their email address.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use AshAuthentication.Sender
|
||||||
|
use MixerWeb, :verified_routes
|
||||||
|
|
||||||
|
import Swoosh.Email
|
||||||
|
|
||||||
|
alias Mixer.Mailer
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def send(user, token, _) do
|
||||||
|
new()
|
||||||
|
# TODO: Replace with your email
|
||||||
|
|> from({"noreply", "noreply@example.com"})
|
||||||
|
|> to(to_string(user.email))
|
||||||
|
|> subject("Confirm your email address")
|
||||||
|
|> html_body(body(token: token))
|
||||||
|
|> Mailer.deliver!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp body(params) do
|
||||||
|
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||||
|
|
||||||
|
"""
|
||||||
|
<p>Click this link to confirm your email:</p>
|
||||||
|
<p><a href="#{url}">#{url}</a></p>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
32
lib/mixer/accounts/user/senders/send_password_reset_email.ex
Normal file
32
lib/mixer/accounts/user/senders/send_password_reset_email.ex
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
|
@moduledoc """
|
||||||
|
Sends a password reset email
|
||||||
|
"""
|
||||||
|
|
||||||
|
use AshAuthentication.Sender
|
||||||
|
use MixerWeb, :verified_routes
|
||||||
|
|
||||||
|
import Swoosh.Email
|
||||||
|
|
||||||
|
alias Mixer.Mailer
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def send(user, token, _) do
|
||||||
|
new()
|
||||||
|
# TODO: Replace with your email
|
||||||
|
|> from({"noreply", "noreply@example.com"})
|
||||||
|
|> to(to_string(user.email))
|
||||||
|
|> subject("Reset your password")
|
||||||
|
|> html_body(body(token: token))
|
||||||
|
|> Mailer.deliver!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp body(params) do
|
||||||
|
url = url(~p"/password-reset/#{params[:token]}")
|
||||||
|
|
||||||
|
"""
|
||||||
|
<p>Click this link to reset your password:</p>
|
||||||
|
<p><a href="#{url}">#{url}</a></p>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
37
lib/mixer/application.ex
Normal file
37
lib/mixer/application.ex
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
defmodule Mixer.Application do
|
||||||
|
# See https://hexdocs.pm/elixir/Application.html
|
||||||
|
# for more information on OTP Applications
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
MixerWeb.Telemetry,
|
||||||
|
Mixer.Repo,
|
||||||
|
{DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore},
|
||||||
|
{Phoenix.PubSub, name: Mixer.PubSub},
|
||||||
|
# Start a worker by calling: Mixer.Worker.start_link(arg)
|
||||||
|
# {Mixer.Worker, arg},
|
||||||
|
# Start to serve requests, typically the last entry
|
||||||
|
MixerWeb.Endpoint,
|
||||||
|
{Absinthe.Subscription, MixerWeb.Endpoint},
|
||||||
|
AshGraphql.Subscription.Batcher,
|
||||||
|
{AshAuthentication.Supervisor, [otp_app: :mixer]}
|
||||||
|
]
|
||||||
|
|
||||||
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
# for other strategies and supported options
|
||||||
|
opts = [strategy: :one_for_one, name: Mixer.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tell Phoenix to update the endpoint configuration
|
||||||
|
# whenever the application is updated.
|
||||||
|
@impl true
|
||||||
|
def config_change(changed, _new, removed) do
|
||||||
|
MixerWeb.Endpoint.config_change(changed, removed)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
3
lib/mixer/mailer.ex
Normal file
3
lib/mixer/mailer.ex
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
defmodule Mixer.Mailer do
|
||||||
|
use Swoosh.Mailer, otp_app: :mixer
|
||||||
|
end
|
||||||
22
lib/mixer/repo.ex
Normal file
22
lib/mixer/repo.ex
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
defmodule Mixer.Repo do
|
||||||
|
use AshPostgres.Repo,
|
||||||
|
otp_app: :mixer
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def installed_extensions do
|
||||||
|
# Add extensions here, and the migration generator will install them.
|
||||||
|
["ash-functions", "citext"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Don't open unnecessary transactions
|
||||||
|
# will default to `false` in 4.0
|
||||||
|
@impl true
|
||||||
|
def prefer_transaction? do
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def min_pg_version do
|
||||||
|
%Version{major: 18, minor: 3, patch: 0}
|
||||||
|
end
|
||||||
|
end
|
||||||
12
lib/mixer/secrets.ex
Normal file
12
lib/mixer/secrets.ex
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
defmodule Mixer.Secrets do
|
||||||
|
use AshAuthentication.Secret
|
||||||
|
|
||||||
|
def secret_for(
|
||||||
|
[:authentication, :tokens, :signing_secret],
|
||||||
|
Mixer.Accounts.User,
|
||||||
|
_opts,
|
||||||
|
_context
|
||||||
|
) do
|
||||||
|
Application.fetch_env(:mixer, :token_signing_secret)
|
||||||
|
end
|
||||||
|
end
|
||||||
114
lib/mixer_web.ex
Normal file
114
lib/mixer_web.ex
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
defmodule MixerWeb do
|
||||||
|
@moduledoc """
|
||||||
|
The entrypoint for defining your web interface, such
|
||||||
|
as controllers, components, channels, and so on.
|
||||||
|
|
||||||
|
This can be used in your application as:
|
||||||
|
|
||||||
|
use MixerWeb, :controller
|
||||||
|
use MixerWeb, :html
|
||||||
|
|
||||||
|
The definitions below will be executed for every controller,
|
||||||
|
component, etc, so keep them short and clean, focused
|
||||||
|
on imports, uses and aliases.
|
||||||
|
|
||||||
|
Do NOT define functions inside the quoted expressions
|
||||||
|
below. Instead, define additional modules and import
|
||||||
|
those modules here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||||
|
|
||||||
|
def router do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Router, helpers: false
|
||||||
|
|
||||||
|
# Import common connection and controller functions to use in pipelines
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
import Phoenix.LiveView.Router
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Channel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def controller do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Controller, formats: [:html, :json]
|
||||||
|
|
||||||
|
use Gettext, backend: MixerWeb.Gettext
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
unquote(verified_routes())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_view do
|
||||||
|
quote do
|
||||||
|
use Phoenix.LiveView
|
||||||
|
|
||||||
|
unquote(html_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_component do
|
||||||
|
quote do
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
|
unquote(html_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def html do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
# Import convenience functions from controllers
|
||||||
|
import Phoenix.Controller,
|
||||||
|
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||||
|
|
||||||
|
# Include general helpers for rendering HTML
|
||||||
|
unquote(html_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp html_helpers do
|
||||||
|
quote do
|
||||||
|
# Translation
|
||||||
|
use Gettext, backend: MixerWeb.Gettext
|
||||||
|
|
||||||
|
# HTML escaping functionality
|
||||||
|
import Phoenix.HTML
|
||||||
|
# Core UI components
|
||||||
|
import MixerWeb.CoreComponents
|
||||||
|
|
||||||
|
# Common modules used in templates
|
||||||
|
alias Phoenix.LiveView.JS
|
||||||
|
alias MixerWeb.Layouts
|
||||||
|
|
||||||
|
# Routes generation with the ~p sigil
|
||||||
|
unquote(verified_routes())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def verified_routes do
|
||||||
|
quote do
|
||||||
|
use Phoenix.VerifiedRoutes,
|
||||||
|
endpoint: MixerWeb.Endpoint,
|
||||||
|
router: MixerWeb.Router,
|
||||||
|
statics: MixerWeb.static_paths()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
When used, dispatch to the appropriate controller/live_view/etc.
|
||||||
|
"""
|
||||||
|
defmacro __using__(which) when is_atom(which) do
|
||||||
|
apply(__MODULE__, which, [])
|
||||||
|
end
|
||||||
|
end
|
||||||
5
lib/mixer_web/ash_json_api_router.ex
Normal file
5
lib/mixer_web/ash_json_api_router.ex
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
defmodule MixerWeb.AshJsonApiRouter do
|
||||||
|
use AshJsonApi.Router,
|
||||||
|
domains: [],
|
||||||
|
open_api: "/open_api"
|
||||||
|
end
|
||||||
20
lib/mixer_web/auth_overrides.ex
Normal file
20
lib/mixer_web/auth_overrides.ex
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
defmodule MixerWeb.AuthOverrides do
|
||||||
|
use AshAuthentication.Phoenix.Overrides
|
||||||
|
|
||||||
|
# configure your UI overrides here
|
||||||
|
|
||||||
|
# First argument to `override` is the component name you are overriding.
|
||||||
|
# The body contains any number of configurations you wish to override
|
||||||
|
# Below are some examples
|
||||||
|
|
||||||
|
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||||
|
|
||||||
|
# override AshAuthentication.Phoenix.Components.Banner do
|
||||||
|
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
|
||||||
|
# set :text_class, "bg-red-500"
|
||||||
|
# end
|
||||||
|
|
||||||
|
# override AshAuthentication.Phoenix.Components.SignIn do
|
||||||
|
# set :show_banner, false
|
||||||
|
# end
|
||||||
|
end
|
||||||
498
lib/mixer_web/components/core_components.ex
Normal file
498
lib/mixer_web/components/core_components.ex
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
defmodule MixerWeb.CoreComponents do
|
||||||
|
@moduledoc """
|
||||||
|
Provides core UI components.
|
||||||
|
|
||||||
|
At first glance, this module may seem daunting, but its goal is to provide
|
||||||
|
core building blocks for your application, such as tables, forms, and
|
||||||
|
inputs. The components consist mostly of markup and are well-documented
|
||||||
|
with doc strings and declarative assigns. You may customize and style
|
||||||
|
them in any way you want, based on your application growth and needs.
|
||||||
|
|
||||||
|
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
|
||||||
|
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
|
||||||
|
and themes. Here are useful references:
|
||||||
|
|
||||||
|
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
|
||||||
|
started and see the available components.
|
||||||
|
|
||||||
|
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
|
||||||
|
we build on. You will use it for layout, sizing, flexbox, grid, and
|
||||||
|
spacing.
|
||||||
|
|
||||||
|
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||||||
|
|
||||||
|
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||||||
|
the component system used by Phoenix. Some components, such as `<.link>`
|
||||||
|
and `<.form>`, are defined there.
|
||||||
|
|
||||||
|
"""
|
||||||
|
use Phoenix.Component
|
||||||
|
use Gettext, backend: MixerWeb.Gettext
|
||||||
|
|
||||||
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders flash notices.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.flash kind={:info} flash={@flash} />
|
||||||
|
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||||
|
"""
|
||||||
|
attr :id, :string, doc: "the optional id of flash container"
|
||||||
|
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||||
|
attr :title, :string, default: nil
|
||||||
|
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||||
|
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||||
|
|
||||||
|
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||||
|
|
||||||
|
def flash(assigns) do
|
||||||
|
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||||
|
id={@id}
|
||||||
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
|
role="alert"
|
||||||
|
class="toast toast-top toast-end z-50"
|
||||||
|
{@rest}
|
||||||
|
>
|
||||||
|
<div class={[
|
||||||
|
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||||
|
@kind == :info && "alert-info",
|
||||||
|
@kind == :error && "alert-error"
|
||||||
|
]}>
|
||||||
|
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||||
|
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p :if={@title} class="font-semibold">{@title}</p>
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||||
|
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a button with navigation support.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.button>Send!</.button>
|
||||||
|
<.button phx-click="go" variant="primary">Send!</.button>
|
||||||
|
<.button navigate={~p"/"}>Home</.button>
|
||||||
|
"""
|
||||||
|
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||||||
|
attr :class, :any
|
||||||
|
attr :variant, :string, values: ~w(primary)
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def button(%{rest: rest} = assigns) do
|
||||||
|
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assign_new(assigns, :class, fn ->
|
||||||
|
["btn", Map.fetch!(variants, assigns[:variant])]
|
||||||
|
end)
|
||||||
|
|
||||||
|
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||||
|
~H"""
|
||||||
|
<.link class={@class} {@rest}>
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</.link>
|
||||||
|
"""
|
||||||
|
else
|
||||||
|
~H"""
|
||||||
|
<button class={@class} {@rest}>
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders an input with label and error messages.
|
||||||
|
|
||||||
|
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||||
|
which is used to retrieve the input name, id, and values.
|
||||||
|
Otherwise all attributes may be passed explicitly.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
This function accepts all HTML input types, considering that:
|
||||||
|
|
||||||
|
* You may also set `type="select"` to render a `<select>` tag
|
||||||
|
|
||||||
|
* `type="checkbox"` is used exclusively to render boolean values
|
||||||
|
|
||||||
|
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||||
|
|
||||||
|
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||||
|
for more information. Unsupported types, such as radio, are best
|
||||||
|
written directly in your templates.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<.input field={@form[:email]} type="email" />
|
||||||
|
<.input name="my-input" errors={["oh no!"]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Select type
|
||||||
|
|
||||||
|
When using `type="select"`, you must pass the `options` and optionally
|
||||||
|
a `value` to mark which option should be preselected.
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information on what kind of data can be passed to `options` see
|
||||||
|
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
|
||||||
|
"""
|
||||||
|
attr :id, :any, default: nil
|
||||||
|
attr :name, :any
|
||||||
|
attr :label, :string, default: nil
|
||||||
|
attr :value, :any
|
||||||
|
|
||||||
|
attr :type, :string,
|
||||||
|
default: "text",
|
||||||
|
values: ~w(checkbox color date datetime-local email file month number password
|
||||||
|
search select tel text textarea time url week hidden)
|
||||||
|
|
||||||
|
attr :field, Phoenix.HTML.FormField,
|
||||||
|
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||||
|
|
||||||
|
attr :errors, :list, default: []
|
||||||
|
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||||
|
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||||
|
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||||
|
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||||
|
attr :class, :any, default: nil, doc: "the input class to use over defaults"
|
||||||
|
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
|
||||||
|
|
||||||
|
attr :rest, :global,
|
||||||
|
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||||
|
multiple pattern placeholder readonly required rows size step)
|
||||||
|
|
||||||
|
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||||
|
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||||
|
|
||||||
|
assigns
|
||||||
|
|> assign(field: nil, id: assigns.id || field.id)
|
||||||
|
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||||
|
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||||
|
|> assign_new(:value, fn -> field.value end)
|
||||||
|
|> input()
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "hidden"} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "checkbox"} = assigns) do
|
||||||
|
assigns =
|
||||||
|
assign_new(assigns, :checked, fn ->
|
||||||
|
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||||
|
end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label for={@id}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={@name}
|
||||||
|
value="false"
|
||||||
|
disabled={@rest[:disabled]}
|
||||||
|
form={@rest[:form]}
|
||||||
|
/>
|
||||||
|
<span class="label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={@id}
|
||||||
|
name={@name}
|
||||||
|
value="true"
|
||||||
|
checked={@checked}
|
||||||
|
class={@class || "checkbox checkbox-sm"}
|
||||||
|
{@rest}
|
||||||
|
/>{@label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "select"} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label for={@id}>
|
||||||
|
<span :if={@label} class="label mb-1">{@label}</span>
|
||||||
|
<select
|
||||||
|
id={@id}
|
||||||
|
name={@name}
|
||||||
|
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
|
||||||
|
multiple={@multiple}
|
||||||
|
{@rest}
|
||||||
|
>
|
||||||
|
<option :if={@prompt} value="">{@prompt}</option>
|
||||||
|
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "textarea"} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label for={@id}>
|
||||||
|
<span :if={@label} class="label mb-1">{@label}</span>
|
||||||
|
<textarea
|
||||||
|
id={@id}
|
||||||
|
name={@name}
|
||||||
|
class={[
|
||||||
|
@class || "w-full textarea",
|
||||||
|
@errors != [] && (@error_class || "textarea-error")
|
||||||
|
]}
|
||||||
|
{@rest}
|
||||||
|
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||||
|
def input(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label for={@id}>
|
||||||
|
<span :if={@label} class="label mb-1">{@label}</span>
|
||||||
|
<input
|
||||||
|
type={@type}
|
||||||
|
name={@name}
|
||||||
|
id={@id}
|
||||||
|
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||||
|
class={[
|
||||||
|
@class || "w-full input",
|
||||||
|
@errors != [] && (@error_class || "input-error")
|
||||||
|
]}
|
||||||
|
{@rest}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper used by inputs to generate form errors
|
||||||
|
defp error(assigns) do
|
||||||
|
~H"""
|
||||||
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</p>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a header with title.
|
||||||
|
"""
|
||||||
|
slot :inner_block, required: true
|
||||||
|
slot :subtitle
|
||||||
|
slot :actions
|
||||||
|
|
||||||
|
def header(assigns) do
|
||||||
|
~H"""
|
||||||
|
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-semibold leading-8">
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</h1>
|
||||||
|
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||||
|
{render_slot(@subtitle)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">{render_slot(@actions)}</div>
|
||||||
|
</header>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a table with generic styling.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.table id="users" rows={@users}>
|
||||||
|
<:col :let={user} label="id">{user.id}</:col>
|
||||||
|
<:col :let={user} label="username">{user.username}</:col>
|
||||||
|
</.table>
|
||||||
|
"""
|
||||||
|
attr :id, :string, required: true
|
||||||
|
attr :rows, :list, required: true
|
||||||
|
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||||
|
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||||
|
|
||||||
|
attr :row_item, :any,
|
||||||
|
default: &Function.identity/1,
|
||||||
|
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||||
|
|
||||||
|
slot :col, required: true do
|
||||||
|
attr :label, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||||
|
|
||||||
|
def table(assigns) do
|
||||||
|
assigns =
|
||||||
|
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||||
|
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th :for={col <- @col}>{col[:label]}</th>
|
||||||
|
<th :if={@action != []}>
|
||||||
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||||
|
<td
|
||||||
|
:for={col <- @col}
|
||||||
|
phx-click={@row_click && @row_click.(row)}
|
||||||
|
class={@row_click && "hover:cursor-pointer"}
|
||||||
|
>
|
||||||
|
{render_slot(col, @row_item.(row))}
|
||||||
|
</td>
|
||||||
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<%= for action <- @action do %>
|
||||||
|
{render_slot(action, @row_item.(row))}
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a data list.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.list>
|
||||||
|
<:item title="Title">{@post.title}</:item>
|
||||||
|
<:item title="Views">{@post.views}</:item>
|
||||||
|
</.list>
|
||||||
|
"""
|
||||||
|
slot :item, required: true do
|
||||||
|
attr :title, :string, required: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def list(assigns) do
|
||||||
|
~H"""
|
||||||
|
<ul class="list">
|
||||||
|
<li :for={item <- @item} class="list-row">
|
||||||
|
<div class="list-col-grow">
|
||||||
|
<div class="font-bold">{item.title}</div>
|
||||||
|
<div>{render_slot(item)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a [Heroicon](https://heroicons.com).
|
||||||
|
|
||||||
|
Heroicons come in three styles – outline, solid, and mini.
|
||||||
|
By default, the outline style is used, but solid and mini may
|
||||||
|
be applied by using the `-solid` and `-mini` suffix.
|
||||||
|
|
||||||
|
You can customize the size and colors of the icons by setting
|
||||||
|
width, height, and background color classes.
|
||||||
|
|
||||||
|
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||||
|
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.icon name="hero-x-mark" />
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
"""
|
||||||
|
attr :name, :string, required: true
|
||||||
|
attr :class, :any, default: "size-4"
|
||||||
|
|
||||||
|
def icon(%{name: "hero-" <> _} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<span class={[@name, @class]} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
## JS Commands
|
||||||
|
|
||||||
|
def show(js \\ %JS{}, selector) do
|
||||||
|
JS.show(js,
|
||||||
|
to: selector,
|
||||||
|
time: 300,
|
||||||
|
transition:
|
||||||
|
{"transition-all ease-out duration-300",
|
||||||
|
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||||
|
"opacity-100 translate-y-0 sm:scale-100"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def hide(js \\ %JS{}, selector) do
|
||||||
|
JS.hide(js,
|
||||||
|
to: selector,
|
||||||
|
time: 200,
|
||||||
|
transition:
|
||||||
|
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
|
||||||
|
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates an error message using gettext.
|
||||||
|
"""
|
||||||
|
def translate_error({msg, opts}) do
|
||||||
|
# When using gettext, we typically pass the strings we want
|
||||||
|
# to translate as a static argument:
|
||||||
|
#
|
||||||
|
# # Translate the number of files with plural rules
|
||||||
|
# dngettext("errors", "1 file", "%{count} files", count)
|
||||||
|
#
|
||||||
|
# However the error messages in our forms and APIs are generated
|
||||||
|
# dynamically, so we need to translate them by calling Gettext
|
||||||
|
# with our gettext backend as first argument. Translations are
|
||||||
|
# available in the errors.po file (as we use the "errors" domain).
|
||||||
|
if count = opts[:count] do
|
||||||
|
Gettext.dngettext(MixerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||||
|
else
|
||||||
|
Gettext.dgettext(MixerWeb.Gettext, "errors", msg, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates the errors for a field from a keyword list of errors.
|
||||||
|
"""
|
||||||
|
def translate_errors(errors, field) when is_list(errors) do
|
||||||
|
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||||
|
end
|
||||||
|
end
|
||||||
154
lib/mixer_web/components/layouts.ex
Normal file
154
lib/mixer_web/components/layouts.ex
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
defmodule MixerWeb.Layouts do
|
||||||
|
@moduledoc """
|
||||||
|
This module holds layouts and related functionality
|
||||||
|
used by your application.
|
||||||
|
"""
|
||||||
|
use MixerWeb, :html
|
||||||
|
|
||||||
|
# Embed all files in layouts/* within this module.
|
||||||
|
# The default root.html.heex file contains the HTML
|
||||||
|
# skeleton of your application, namely HTML headers
|
||||||
|
# and other static content.
|
||||||
|
embed_templates "layouts/*"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders your app layout.
|
||||||
|
|
||||||
|
This function is typically invoked from every template,
|
||||||
|
and it often contains your application menu, sidebar,
|
||||||
|
or similar.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<h1>Content</h1>
|
||||||
|
</Layouts.app>
|
||||||
|
|
||||||
|
"""
|
||||||
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||||
|
|
||||||
|
attr :current_scope, :map,
|
||||||
|
default: nil,
|
||||||
|
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||||
|
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def app(assigns) do
|
||||||
|
~H"""
|
||||||
|
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||||
|
<img src={~p"/images/logo.svg"} width="36" />
|
||||||
|
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ul class="flex flex-column px-1 space-x-4 items-center">
|
||||||
|
<li>
|
||||||
|
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.theme_toggle />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
|
||||||
|
Get Started <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||||
|
<div class="mx-auto max-w-2xl space-y-4">
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Shows the flash group with standard titles and content.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
"""
|
||||||
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||||
|
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||||
|
|
||||||
|
def flash_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div id={@id} aria-live="polite">
|
||||||
|
<.flash kind={:info} flash={@flash} />
|
||||||
|
<.flash kind={:error} flash={@flash} />
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="client-error"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("We can't find the internet")}
|
||||||
|
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
||||||
|
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="server-error"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("Something went wrong!")}
|
||||||
|
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
||||||
|
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Provides dark vs light theme toggle based on themes defined in app.css.
|
||||||
|
|
||||||
|
See <head> in root.html.heex which applies the theme before page load.
|
||||||
|
"""
|
||||||
|
def theme_toggle(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||||
|
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex p-2 cursor-pointer w-1/3"
|
||||||
|
phx-click={JS.dispatch("phx:set-theme")}
|
||||||
|
data-phx-theme="system"
|
||||||
|
>
|
||||||
|
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex p-2 cursor-pointer w-1/3"
|
||||||
|
phx-click={JS.dispatch("phx:set-theme")}
|
||||||
|
data-phx-theme="light"
|
||||||
|
>
|
||||||
|
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex p-2 cursor-pointer w-1/3"
|
||||||
|
phx-click={JS.dispatch("phx:set-theme")}
|
||||||
|
data-phx-theme="dark"
|
||||||
|
>
|
||||||
|
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
36
lib/mixer_web/components/layouts/root.html.heex
Normal file
36
lib/mixer_web/components/layouts/root.html.heex
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
|
<.live_title default="Mixer" suffix=" · Phoenix Framework">
|
||||||
|
{assigns[:page_title]}
|
||||||
|
</.live_title>
|
||||||
|
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||||
|
<script defer phx-track-static type="module" src={~p"/assets/app.js"}>
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (theme === "system") {
|
||||||
|
localStorage.removeItem("phx:theme");
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("phx:theme", theme);
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||||
|
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||||
|
}
|
||||||
|
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||||
|
|
||||||
|
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{@inner_content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
lib/mixer_web/components/layouts/spa_root.html.heex
Normal file
19
lib/mixer_web/components/layouts/spa_root.html.heex
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
|
<.live_title default="AshTypescript">Page</.live_title>
|
||||||
|
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{@inner_content}
|
||||||
|
<script defer phx-track-static type="module" src={~p"/assets/index.js"}>
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
lib/mixer_web/controllers/ash_typescript_rpc_controller.ex
Normal file
13
lib/mixer_web/controllers/ash_typescript_rpc_controller.ex
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule MixerWeb.AshTypescriptRpcController do
|
||||||
|
use MixerWeb, :controller
|
||||||
|
|
||||||
|
def run(conn, params) do
|
||||||
|
result = AshTypescript.Rpc.run_action(:mixer, conn, params)
|
||||||
|
json(conn, result)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate(conn, params) do
|
||||||
|
result = AshTypescript.Rpc.validate_action(:mixer, conn, params)
|
||||||
|
json(conn, result)
|
||||||
|
end
|
||||||
|
end
|
||||||
55
lib/mixer_web/controllers/auth_controller.ex
Normal file
55
lib/mixer_web/controllers/auth_controller.ex
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
defmodule MixerWeb.AuthController do
|
||||||
|
use MixerWeb, :controller
|
||||||
|
use AshAuthentication.Phoenix.Controller
|
||||||
|
|
||||||
|
def success(conn, activity, user, _token) do
|
||||||
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
|
message =
|
||||||
|
case activity do
|
||||||
|
{:confirm_new_user, :confirm} -> "Your email address has now been confirmed"
|
||||||
|
{:password, :reset} -> "Your password has successfully been reset"
|
||||||
|
_ -> "You are now signed in"
|
||||||
|
end
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> delete_session(:return_to)
|
||||||
|
|> store_in_session(user)
|
||||||
|
# If your resource has a different name, update the assign name here (i.e :current_admin)
|
||||||
|
|> assign(:current_user, user)
|
||||||
|
|> put_flash(:info, message)
|
||||||
|
|> redirect(to: return_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(conn, activity, reason) do
|
||||||
|
message =
|
||||||
|
case {activity, reason} do
|
||||||
|
{_,
|
||||||
|
%AshAuthentication.Errors.AuthenticationFailed{
|
||||||
|
caused_by: %Ash.Error.Forbidden{
|
||||||
|
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
|
||||||
|
}
|
||||||
|
}} ->
|
||||||
|
"""
|
||||||
|
You have already signed in another way, but have not confirmed your account.
|
||||||
|
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
"Incorrect email or password"
|
||||||
|
end
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, message)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_out(conn, _params) do
|
||||||
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> clear_session(:mixer)
|
||||||
|
|> put_flash(:info, "You are now signed out")
|
||||||
|
|> redirect(to: return_to)
|
||||||
|
end
|
||||||
|
end
|
||||||
24
lib/mixer_web/controllers/error_html.ex
Normal file
24
lib/mixer_web/controllers/error_html.ex
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
defmodule MixerWeb.ErrorHTML do
|
||||||
|
@moduledoc """
|
||||||
|
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||||
|
|
||||||
|
See config/config.exs.
|
||||||
|
"""
|
||||||
|
use MixerWeb, :html
|
||||||
|
|
||||||
|
# If you want to customize your error pages,
|
||||||
|
# uncomment the embed_templates/1 call below
|
||||||
|
# and add pages to the error directory:
|
||||||
|
#
|
||||||
|
# * lib/mixer_web/controllers/error_html/404.html.heex
|
||||||
|
# * lib/mixer_web/controllers/error_html/500.html.heex
|
||||||
|
#
|
||||||
|
# embed_templates "error_html/*"
|
||||||
|
|
||||||
|
# The default is to render a plain text page based on
|
||||||
|
# the template name. For example, "404.html" becomes
|
||||||
|
# "Not Found".
|
||||||
|
def render(template, _assigns) do
|
||||||
|
Phoenix.Controller.status_message_from_template(template)
|
||||||
|
end
|
||||||
|
end
|
||||||
21
lib/mixer_web/controllers/error_json.ex
Normal file
21
lib/mixer_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
defmodule MixerWeb.ErrorJSON do
|
||||||
|
@moduledoc """
|
||||||
|
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||||
|
|
||||||
|
See config/config.exs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If you want to customize a particular status code,
|
||||||
|
# you may add your own clauses, such as:
|
||||||
|
#
|
||||||
|
# def render("500.json", _assigns) do
|
||||||
|
# %{errors: %{detail: "Internal Server Error"}}
|
||||||
|
# end
|
||||||
|
|
||||||
|
# By default, Phoenix returns the status message from
|
||||||
|
# the template name. For example, "404.json" becomes
|
||||||
|
# "Not Found".
|
||||||
|
def render(template, _assigns) do
|
||||||
|
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
11
lib/mixer_web/controllers/page_controller.ex
Normal file
11
lib/mixer_web/controllers/page_controller.ex
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
defmodule MixerWeb.PageController do
|
||||||
|
use MixerWeb, :controller
|
||||||
|
|
||||||
|
def home(conn, _params) do
|
||||||
|
render(conn, :home)
|
||||||
|
end
|
||||||
|
|
||||||
|
def index conn, _params do
|
||||||
|
conn |> put_root_layout(html: {MixerWeb.Layouts, :spa_root}) |> render(:index)
|
||||||
|
end
|
||||||
|
end
|
||||||
10
lib/mixer_web/controllers/page_html.ex
Normal file
10
lib/mixer_web/controllers/page_html.ex
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
defmodule MixerWeb.PageHTML do
|
||||||
|
@moduledoc """
|
||||||
|
This module contains pages rendered by PageController.
|
||||||
|
|
||||||
|
See the `page_html` directory for all templates available.
|
||||||
|
"""
|
||||||
|
use MixerWeb, :html
|
||||||
|
|
||||||
|
embed_templates "page_html/*"
|
||||||
|
end
|
||||||
202
lib/mixer_web/controllers/page_html/home.html.heex
Normal file
202
lib/mixer_web/controllers/page_html/home.html.heex
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<Layouts.flash_group flash={@flash} />
|
||||||
|
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1480 957"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-0 h-full w-full"
|
||||||
|
preserveAspectRatio="xMinYMid slice"
|
||||||
|
>
|
||||||
|
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||||
|
<path
|
||||||
|
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
||||||
|
fill="#FF9F92"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
||||||
|
fill="#FA8372"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
||||||
|
fill="#E96856"
|
||||||
|
fill-opacity=".6"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
||||||
|
fill="#C42652"
|
||||||
|
fill-opacity=".2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
||||||
|
fill="#A41C42"
|
||||||
|
fill-opacity=".2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
||||||
|
fill="#A41C42"
|
||||||
|
fill-opacity=".2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||||
|
<div class="mx-auto max-w-xl lg:mx-0">
|
||||||
|
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||||
|
fill="#FD4F00"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="mt-10 flex justify-between items-center">
|
||||||
|
<h1 class="flex items-center text-sm font-semibold leading-6">
|
||||||
|
Phoenix Framework
|
||||||
|
<small class="badge badge-warning badge-sm ml-3">
|
||||||
|
v{Application.spec(:phoenix, :vsn)}
|
||||||
|
</small>
|
||||||
|
</h1>
|
||||||
|
<Layouts.theme_toggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||||
|
Peace of mind from prototype to production.
|
||||||
|
</p>
|
||||||
|
<p class="mt-4 leading-7 text-base-content/70">
|
||||||
|
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
||||||
|
</p>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full sm:w-auto">
|
||||||
|
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||||
|
<a
|
||||||
|
href="https://hexdocs.pm/phoenix/overview.html"
|
||||||
|
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||||
|
>
|
||||||
|
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||||
|
</span>
|
||||||
|
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||||
|
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
|
||||||
|
<path
|
||||||
|
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Guides & Docs
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/phoenixframework/phoenix"
|
||||||
|
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||||
|
>
|
||||||
|
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||||
|
</span>
|
||||||
|
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Source Code
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||||
|
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||||
|
>
|
||||||
|
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||||
|
</span>
|
||||||
|
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||||
|
<path
|
||||||
|
d="M12 1v6M12 17v6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="4"
|
||||||
|
fill="currentColor"
|
||||||
|
fill-opacity=".15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Changelog
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://elixirforum.com"
|
||||||
|
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||||
|
>
|
||||||
|
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||||
|
</svg>
|
||||||
|
Discuss on the Elixir Forum
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/elixir"
|
||||||
|
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||||
|
>
|
||||||
|
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||||
|
</svg>
|
||||||
|
Join our Discord server
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://elixir-slack.community/"
|
||||||
|
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||||
|
>
|
||||||
|
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
|
||||||
|
</svg>
|
||||||
|
Join us on Slack
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://fly.io/docs/elixir/getting-started/"
|
||||||
|
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||||
|
>
|
||||||
|
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||||
|
</svg>
|
||||||
|
Deploy your application
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1
lib/mixer_web/controllers/page_html/index.html.heex
Normal file
1
lib/mixer_web/controllers/page_html/index.html.heex
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div id="app"></div>
|
||||||
65
lib/mixer_web/endpoint.ex
Normal file
65
lib/mixer_web/endpoint.ex
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
defmodule MixerWeb.Endpoint do
|
||||||
|
use Phoenix.Endpoint, otp_app: :mixer
|
||||||
|
use Absinthe.Phoenix.Endpoint
|
||||||
|
|
||||||
|
# The session will be stored in the cookie and signed,
|
||||||
|
# this means its contents can be read but not tampered with.
|
||||||
|
# Set :encryption_salt if you would also like to encrypt it.
|
||||||
|
@session_options [
|
||||||
|
store: :cookie,
|
||||||
|
key: "_mixer_key",
|
||||||
|
signing_salt: "oRInhdZg",
|
||||||
|
same_site: "Lax"
|
||||||
|
]
|
||||||
|
|
||||||
|
socket "/live", Phoenix.LiveView.Socket,
|
||||||
|
websocket: [connect_info: [session: @session_options]],
|
||||||
|
longpoll: [connect_info: [session: @session_options]]
|
||||||
|
|
||||||
|
socket "/ws/gql", MixerWeb.GraphqlSocket, websocket: true, longpoll: true
|
||||||
|
|
||||||
|
# Serve at "/" the static files from "priv/static" directory.
|
||||||
|
#
|
||||||
|
# When code reloading is disabled (e.g., in production),
|
||||||
|
# the `gzip` option is enabled to serve compressed
|
||||||
|
# static files generated by running `phx.digest`.
|
||||||
|
plug Plug.Static,
|
||||||
|
at: "/",
|
||||||
|
from: :mixer,
|
||||||
|
gzip: not code_reloading?,
|
||||||
|
only: MixerWeb.static_paths(),
|
||||||
|
raise_on_missing_only: code_reloading?
|
||||||
|
|
||||||
|
# Code reloading can be explicitly enabled under the
|
||||||
|
# :code_reloader configuration of your endpoint.
|
||||||
|
if code_reloading? do
|
||||||
|
plug AshAi.Mcp.Dev,
|
||||||
|
# For many tools, you will need to set the `protocol_version_statement` to the older version.
|
||||||
|
protocol_version_statement: "2024-11-05",
|
||||||
|
otp_app: :mixer,
|
||||||
|
path: "/ash_ai/mcp"
|
||||||
|
|
||||||
|
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||||
|
plug Phoenix.LiveReloader
|
||||||
|
plug Phoenix.CodeReloader
|
||||||
|
plug AshPhoenix.Plug.CheckCodegenStatus
|
||||||
|
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mixer
|
||||||
|
end
|
||||||
|
|
||||||
|
plug Phoenix.LiveDashboard.RequestLogger,
|
||||||
|
param_key: "request_logger",
|
||||||
|
cookie_key: "request_logger"
|
||||||
|
|
||||||
|
plug Plug.RequestId
|
||||||
|
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||||
|
|
||||||
|
plug Plug.Parsers,
|
||||||
|
parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser, AshJsonApi.Plug.Parser],
|
||||||
|
pass: ["*/*"],
|
||||||
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
||||||
|
plug Plug.MethodOverride
|
||||||
|
plug Plug.Head
|
||||||
|
plug Plug.Session, @session_options
|
||||||
|
plug MixerWeb.Router
|
||||||
|
end
|
||||||
25
lib/mixer_web/gettext.ex
Normal file
25
lib/mixer_web/gettext.ex
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
defmodule MixerWeb.Gettext do
|
||||||
|
@moduledoc """
|
||||||
|
A module providing Internationalization with a gettext-based API.
|
||||||
|
|
||||||
|
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||||
|
that you can use in your application. To use this Gettext backend module,
|
||||||
|
call `use Gettext` and pass it as an option:
|
||||||
|
|
||||||
|
use Gettext, backend: MixerWeb.Gettext
|
||||||
|
|
||||||
|
# Simple translation
|
||||||
|
gettext("Here is the string to translate")
|
||||||
|
|
||||||
|
# Plural translation
|
||||||
|
ngettext("Here is the string to translate",
|
||||||
|
"Here are the strings to translate",
|
||||||
|
3)
|
||||||
|
|
||||||
|
# Domain-based translation
|
||||||
|
dgettext("errors", "Here is the error message to translate")
|
||||||
|
|
||||||
|
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||||
|
"""
|
||||||
|
use Gettext.Backend, otp_app: :mixer
|
||||||
|
end
|
||||||
29
lib/mixer_web/graphql_schema.ex
Normal file
29
lib/mixer_web/graphql_schema.ex
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
defmodule MixerWeb.GraphqlSchema do
|
||||||
|
use Absinthe.Schema
|
||||||
|
|
||||||
|
use AshGraphql,
|
||||||
|
domains: []
|
||||||
|
|
||||||
|
import_types Absinthe.Plug.Types
|
||||||
|
|
||||||
|
query do
|
||||||
|
# Custom Absinthe queries can be placed here
|
||||||
|
@desc """
|
||||||
|
Hello! This is a sample query to verify that AshGraphql has been set up correctly.
|
||||||
|
Remove me once you have a query of your own!
|
||||||
|
"""
|
||||||
|
field :say_hello, :string do
|
||||||
|
resolve fn _, _, _ ->
|
||||||
|
{:ok, "Hello from AshGraphql!"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mutation do
|
||||||
|
# Custom Absinthe mutations can be placed here
|
||||||
|
end
|
||||||
|
|
||||||
|
subscription do
|
||||||
|
# Custom Absinthe subscriptions can be placed here
|
||||||
|
end
|
||||||
|
end
|
||||||
14
lib/mixer_web/graphql_socket.ex
Normal file
14
lib/mixer_web/graphql_socket.ex
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
defmodule MixerWeb.GraphqlSocket do
|
||||||
|
use Phoenix.Socket
|
||||||
|
|
||||||
|
use Absinthe.Phoenix.Socket,
|
||||||
|
schema: MixerWeb.GraphqlSchema
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def connect(_params, socket, _connect_info) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def id(_socket), do: nil
|
||||||
|
end
|
||||||
39
lib/mixer_web/live_user_auth.ex
Normal file
39
lib/mixer_web/live_user_auth.ex
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
defmodule MixerWeb.LiveUserAuth do
|
||||||
|
@moduledoc """
|
||||||
|
Helpers for authenticating users in LiveViews.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component
|
||||||
|
use MixerWeb, :verified_routes
|
||||||
|
|
||||||
|
# This is used for nested liveviews to fetch the current user.
|
||||||
|
# To use, place the following at the top of that liveview:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :current_user}
|
||||||
|
def on_mount(:current_user, _params, session, socket) do
|
||||||
|
{:cont, AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:live_user_optional, _params, _session, socket) do
|
||||||
|
if socket.assigns[:current_user] do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
{:cont, assign(socket, :current_user, nil)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:live_user_required, _params, _session, socket) do
|
||||||
|
if socket.assigns[:current_user] do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:live_no_user, _params, _session, socket) do
|
||||||
|
if socket.assigns[:current_user] do
|
||||||
|
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
|
||||||
|
else
|
||||||
|
{:cont, assign(socket, :current_user, nil)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
145
lib/mixer_web/router.ex
Normal file
145
lib/mixer_web/router.ex
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
defmodule MixerWeb.Router do
|
||||||
|
use MixerWeb, :router
|
||||||
|
|
||||||
|
use AshAuthentication.Phoenix.Router
|
||||||
|
|
||||||
|
import AshAuthentication.Plug.Helpers
|
||||||
|
|
||||||
|
pipeline :graphql do
|
||||||
|
plug :load_from_bearer
|
||||||
|
plug :set_actor, :user
|
||||||
|
plug AshGraphql.Plug
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :browser do
|
||||||
|
plug :accepts, ["html"]
|
||||||
|
plug :fetch_session
|
||||||
|
plug :fetch_live_flash
|
||||||
|
plug :put_root_layout, html: {MixerWeb.Layouts, :root}
|
||||||
|
plug :protect_from_forgery
|
||||||
|
plug :put_secure_browser_headers
|
||||||
|
plug :load_from_session
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :api do
|
||||||
|
plug :accepts, ["json"]
|
||||||
|
|
||||||
|
plug AshAuthentication.Strategy.ApiKey.Plug,
|
||||||
|
resource: Mixer.Accounts.User,
|
||||||
|
# if you want to require an api key to be supplied, set `required?` to true
|
||||||
|
required?: false
|
||||||
|
|
||||||
|
plug :load_from_bearer
|
||||||
|
plug :set_actor, :user
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", MixerWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
ash_authentication_live_session :authenticated_routes do
|
||||||
|
# in each liveview, add one of the following at the top of the module:
|
||||||
|
#
|
||||||
|
# If an authenticated user must be present:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :live_user_required}
|
||||||
|
#
|
||||||
|
# If an authenticated user *may* be present:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :live_user_optional}
|
||||||
|
#
|
||||||
|
# If an authenticated user must *not* be present:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :live_no_user}
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/rpc/run", AshTypescriptRpcController, :run
|
||||||
|
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||||
|
get "/ash-typescript", PageController, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/api/json" do
|
||||||
|
pipe_through [:api]
|
||||||
|
|
||||||
|
forward "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
|
||||||
|
path: "/api/json/open_api",
|
||||||
|
default_model_expand_depth: 4
|
||||||
|
|
||||||
|
forward "/", MixerWeb.AshJsonApiRouter
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/gql" do
|
||||||
|
pipe_through [:graphql]
|
||||||
|
|
||||||
|
forward "/playground", Absinthe.Plug.GraphiQL,
|
||||||
|
schema: Module.concat(["MixerWeb.GraphqlSchema"]),
|
||||||
|
socket: Module.concat(["MixerWeb.GraphqlSocket"]),
|
||||||
|
interface: :simple
|
||||||
|
|
||||||
|
forward "/", Absinthe.Plug, schema: Module.concat(["MixerWeb.GraphqlSchema"])
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", MixerWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
get "/", PageController, :home
|
||||||
|
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
||||||
|
sign_out_route AuthController
|
||||||
|
|
||||||
|
# Remove these if you'd like to use your own authentication views
|
||||||
|
sign_in_route register_path: "/register",
|
||||||
|
reset_path: "/reset",
|
||||||
|
auth_routes_prefix: "/auth",
|
||||||
|
on_mount: [{MixerWeb.LiveUserAuth, :live_no_user}],
|
||||||
|
overrides: [
|
||||||
|
MixerWeb.AuthOverrides,
|
||||||
|
Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI
|
||||||
|
]
|
||||||
|
|
||||||
|
# Remove this if you do not want to use the reset password feature
|
||||||
|
reset_route auth_routes_prefix: "/auth",
|
||||||
|
overrides: [
|
||||||
|
MixerWeb.AuthOverrides,
|
||||||
|
Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI
|
||||||
|
]
|
||||||
|
|
||||||
|
# Remove this if you do not use the confirmation strategy
|
||||||
|
confirm_route Mixer.Accounts.User, :confirm_new_user,
|
||||||
|
auth_routes_prefix: "/auth",
|
||||||
|
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||||
|
|
||||||
|
# Remove this if you do not use the magic link strategy.
|
||||||
|
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
|
||||||
|
auth_routes_prefix: "/auth",
|
||||||
|
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Other scopes may use custom stacks.
|
||||||
|
# scope "/api", MixerWeb do
|
||||||
|
# pipe_through :api
|
||||||
|
# end
|
||||||
|
|
||||||
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
|
if Application.compile_env(:mixer, :dev_routes) do
|
||||||
|
# If you want to use the LiveDashboard in production, you should put
|
||||||
|
# it behind authentication and allow only admins to access it.
|
||||||
|
# If your application does not have an admins-only section yet,
|
||||||
|
# you can use Plug.BasicAuth to set up some basic authentication
|
||||||
|
# as long as you are also using SSL (which you should anyway).
|
||||||
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
|
scope "/dev" do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
live_dashboard "/dashboard", metrics: MixerWeb.Telemetry
|
||||||
|
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if Application.compile_env(:mixer, :dev_routes) do
|
||||||
|
import AshAdmin.Router
|
||||||
|
|
||||||
|
scope "/admin" do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
ash_admin "/"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
93
lib/mixer_web/telemetry.ex
Normal file
93
lib/mixer_web/telemetry.ex
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
defmodule MixerWeb.Telemetry do
|
||||||
|
use Supervisor
|
||||||
|
import Telemetry.Metrics
|
||||||
|
|
||||||
|
def start_link(arg) do
|
||||||
|
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_arg) do
|
||||||
|
children = [
|
||||||
|
# Telemetry poller will execute the given period measurements
|
||||||
|
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||||
|
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||||
|
# Add reporters as children of your supervision tree.
|
||||||
|
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||||
|
]
|
||||||
|
|
||||||
|
Supervisor.init(children, strategy: :one_for_one)
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics do
|
||||||
|
[
|
||||||
|
# Phoenix Metrics
|
||||||
|
summary("phoenix.endpoint.start.system_time",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.endpoint.stop.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.start.system_time",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.exception.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.stop.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.socket_connected.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
sum("phoenix.socket_drain.count"),
|
||||||
|
summary("phoenix.channel_joined.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.channel_handled_in.duration",
|
||||||
|
tags: [:event],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
|
||||||
|
# Database Metrics
|
||||||
|
summary("mixer.repo.query.total_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The sum of the other measurements"
|
||||||
|
),
|
||||||
|
summary("mixer.repo.query.decode_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent decoding the data received from the database"
|
||||||
|
),
|
||||||
|
summary("mixer.repo.query.query_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent executing the query"
|
||||||
|
),
|
||||||
|
summary("mixer.repo.query.queue_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent waiting for a database connection"
|
||||||
|
),
|
||||||
|
summary("mixer.repo.query.idle_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description:
|
||||||
|
"The time the connection spent waiting before being checked out for the query"
|
||||||
|
),
|
||||||
|
|
||||||
|
# VM Metrics
|
||||||
|
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||||
|
summary("vm.total_run_queue_lengths.total"),
|
||||||
|
summary("vm.total_run_queue_lengths.cpu"),
|
||||||
|
summary("vm.total_run_queue_lengths.io")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp periodic_measurements do
|
||||||
|
[
|
||||||
|
# A module, function and arguments to be invoked periodically.
|
||||||
|
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||||
|
# {MixerWeb, :count_users, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
117
mix.exs
Normal file
117
mix.exs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
defmodule Mixer.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :mixer,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.15",
|
||||||
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
aliases: aliases(),
|
||||||
|
deps: deps(),
|
||||||
|
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||||
|
listeners: [Phoenix.CodeReloader],
|
||||||
|
consolidate_protocols: Mix.env() != :dev
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration for the OTP application.
|
||||||
|
#
|
||||||
|
# Type `mix help compile.app` for more information.
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
mod: {Mixer.Application, []},
|
||||||
|
extra_applications: [:logger, :runtime_tools]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def cli do
|
||||||
|
[
|
||||||
|
preferred_envs: [precommit: :test]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Specifies which paths to compile per environment.
|
||||||
|
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||||
|
defp elixirc_paths(_), do: ["lib"]
|
||||||
|
|
||||||
|
# Specifies your project dependencies.
|
||||||
|
#
|
||||||
|
# Type `mix help deps` for examples and options.
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:bcrypt_elixir, "~> 3.0"},
|
||||||
|
{:picosat_elixir, "~> 0.2"},
|
||||||
|
{:absinthe_phoenix, "~> 2.0"},
|
||||||
|
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||||
|
{:open_api_spex, "~> 3.0"},
|
||||||
|
{:ash_typescript, "~> 0.17"},
|
||||||
|
{:usage_rules, "~> 1.0", only: [:dev]},
|
||||||
|
{:ash_ai, "~> 0.5"},
|
||||||
|
{:ash_state_machine, "~> 0.2"},
|
||||||
|
{:ash_admin, "~> 0.14"},
|
||||||
|
{:ash_authentication_phoenix, "~> 2.0"},
|
||||||
|
{:ash_authentication, "~> 4.0"},
|
||||||
|
{:ash_postgres, "~> 2.0"},
|
||||||
|
{:ash_json_api, "~> 1.0"},
|
||||||
|
{:ash_graphql, "~> 1.0"},
|
||||||
|
{:ash_phoenix, "~> 2.0"},
|
||||||
|
{:ash, "~> 3.0"},
|
||||||
|
{:igniter, "~> 0.6", only: [:dev, :test]},
|
||||||
|
{:phoenix, "~> 1.8.5"},
|
||||||
|
{:phoenix_ecto, "~> 4.5"},
|
||||||
|
{:ecto_sql, "~> 3.13"},
|
||||||
|
{:postgrex, ">= 0.0.0"},
|
||||||
|
{:phoenix_html, "~> 4.1"},
|
||||||
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
|
{:phoenix_live_view, "~> 1.1.0"},
|
||||||
|
{:lazy_html, ">= 0.1.0", only: :test},
|
||||||
|
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||||
|
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||||
|
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
||||||
|
{:heroicons,
|
||||||
|
github: "tailwindlabs/heroicons",
|
||||||
|
tag: "v2.2.0",
|
||||||
|
sparse: "optimized",
|
||||||
|
app: false,
|
||||||
|
compile: false,
|
||||||
|
depth: 1},
|
||||||
|
{:swoosh, "~> 1.16"},
|
||||||
|
{:req, "~> 0.5"},
|
||||||
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
|
{:telemetry_poller, "~> 1.0"},
|
||||||
|
{:gettext, "~> 1.0"},
|
||||||
|
{:jason, "~> 1.2"},
|
||||||
|
{:dns_cluster, "~> 0.2.0"},
|
||||||
|
{:bandit, "~> 1.5"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Aliases are shortcuts or tasks specific to the current project.
|
||||||
|
# For example, to install project dependencies and perform other setup tasks, run:
|
||||||
|
#
|
||||||
|
# $ mix setup
|
||||||
|
#
|
||||||
|
# See the documentation for `Mix` for more info on aliases.
|
||||||
|
defp aliases do
|
||||||
|
[
|
||||||
|
setup: ["deps.get", "ash.setup", "assets.setup", "assets.build", "run priv/repo/seeds.exs"],
|
||||||
|
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||||
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
|
test: ["ash.setup --quiet", "test"],
|
||||||
|
"assets.setup": [
|
||||||
|
"tailwind.install --if-missing",
|
||||||
|
"esbuild.install --if-missing",
|
||||||
|
"ash_typescript.npm_install"
|
||||||
|
],
|
||||||
|
"assets.build": ["compile", "tailwind mixer", "esbuild mixer"],
|
||||||
|
"assets.deploy": [
|
||||||
|
"tailwind mixer --minify",
|
||||||
|
"esbuild mixer --minify",
|
||||||
|
"phx.digest"
|
||||||
|
],
|
||||||
|
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
98
mix.lock
Normal file
98
mix.lock
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
%{
|
||||||
|
"absinthe": {:hex, :absinthe, "1.9.1", "19fe8614d5cdabefaf127ee224cb89eceea48314de4d709737451b43b5bdedd5", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d93e1aa61d68b974f48d5660104cb911ae045ee3a5d69954d251f91f3dbe2077"},
|
||||||
|
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"},
|
||||||
|
"absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
|
||||||
|
"ash": {:hex, :ash, "3.22.1", "9cc8ea1f29e62dde48b5e8372ddd5f86e3c69734d33597e1fc0499aab65881d2", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "009fae36cbdeb9931e1eb02c7c33dda6ee5ba21e39a9b4fc3c28df5b06719ab9"},
|
||||||
|
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
|
||||||
|
"ash_ai": {:hex, :ash_ai, "0.5.0", "5b81c0428a2314bd033ca171bee2ab4b072ab25588a069330fc1afaab9051e43", [:mix], [{:ash, ">= 3.7.1 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.8", [hex: :ash_authentication, repo: "hexpm", optional: true]}, {:ash_json_api, ">= 1.4.27 and < 2.0.0-0", [hex: :ash_json_api, repo: "hexpm", optional: false]}, {:ash_oban, "~> 0.5", [hex: :ash_oban, repo: "hexpm", optional: true]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: true]}, {:ash_postgres, "~> 2.5", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:langchain, "~> 0.4", [hex: :langchain, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.0", [hex: :open_api_spex, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "62fdf6cffa1b3338ef536d8db5ffc6713cf42d5d2a96528551669e4574dc736d"},
|
||||||
|
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||||
|
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
|
||||||
|
"ash_graphql": {:hex, :ash_graphql, "1.9.3", "02d5a8e34a9fb22267513dab6e91380d5aaaffb9d8403d78a26cf1eba655f743", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.28 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "dc3a8dbab4858a655c3f58813f1e1d8f815886d8eb0a9536601c52ceddbab521"},
|
||||||
|
"ash_json_api": {:hex, :ash_json_api, "1.6.4", "5d41ce9f77cd1c291be391ffbae74f27a674b8abb0a60ebe6056aa8599957c94", [:mix], [{:ash, ">= 3.19.1 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.58 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "153aeb7e6234c2f5cf31a8e52bfebdba6de372fe209b8f411be8c7470515e648"},
|
||||||
|
"ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"},
|
||||||
|
"ash_postgres": {:hex, :ash_postgres, "2.8.0", "bf1a30c57b15ae3622f1cea34959b3a11c33f0f4319830dd28235a3c0c79f647", [:mix], [{:ash, "~> 3.19", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "06cd4278ccc838104fdbc92e0f9508cf75c4f3376a922ec7e6d85bc7192d616d"},
|
||||||
|
"ash_sql": {:hex, :ash_sql, "0.5.3", "0151ade6153b5d6ec152dea5c03743d8e468fa4696f3d9796d4d1e0209d200d4", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "6022d9ae5edfab0b4a5220546200aa5112fff897c980c9f46e49e5e939e5c085"},
|
||||||
|
"ash_state_machine": {:hex, :ash_state_machine, "0.2.12", "c0f7ebb8a176584f70c6ed196b7d0118c930d73e0590ade705d2dddc48aa7311", [:mix], [{:ash, ">= 3.4.66 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "394ce761ce82358e3c715e1cae6c5cf1390be27c03a8b661f2e5a2fda849873d"},
|
||||||
|
"ash_typescript": {:hex, :ash_typescript, "0.17.0", "b0d0a60309cf8903b9d70a4e20bb199c00c3c45efc42661666045aa518a69b54", [:mix], [{:ash, ">= 3.21.1 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "189a58b63c4ed278a54423e3bc1b8764fa456e53d619248524d907e85956d628"},
|
||||||
|
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||||
|
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
||||||
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||||
|
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||||
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
|
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||||
|
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||||
|
"conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"},
|
||||||
|
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||||
|
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||||
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
|
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
|
||||||
|
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||||
|
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||||
|
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||||
|
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||||
|
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||||
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
|
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||||
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
|
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||||
|
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||||
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
|
"igniter": {:hex, :igniter, "0.7.7", "08bae07b7b610100bc7c676e6b18130fe12bb90617982023cc798346879c2c5f", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "caeb1227887362b22038ff8419a7e6ddd3888f3d7e6cffacb14c73abbce17600"},
|
||||||
|
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||||
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
|
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||||
|
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||||
|
"json_xema": {:hex, :json_xema, "0.6.5", "060459c9c9152650edb4427b1acbc61fa43a23bcea0301d200cafa76e0880f37", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.16", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "b8ffdbc2f67aa8b91b44e1ba0ab77eb5c0b0142116f8fbb804977fb939d470ef"},
|
||||||
|
"langchain": {:hex, :langchain, "0.6.3", "88794ef059c97521279996571ac77daf15f8ccf7c6ccad2716d969cbb052d6ca", [:mix], [{:abacus, "~> 2.1.0", [hex: :abacus, repo: "hexpm", optional: true]}, {:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10", [hex: :ecto, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26.2 or ~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: true]}, {:nx, ">= 0.7.0", [hex: :nx, repo: "hexpm", optional: true]}, {:req, ">= 0.5.3", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "9f0eeac704bc1b01fb91e0ae38559f9e643b2e067c8e98c43ba06f2213aa14f4"},
|
||||||
|
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
|
||||||
|
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||||
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
|
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||||
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||||
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
|
"open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"},
|
||||||
|
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||||
|
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||||
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||||
|
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
|
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||||
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||||
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||||
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
||||||
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
|
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||||
|
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||||
|
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||||
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
|
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
|
||||||
|
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
|
||||||
|
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||||
|
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
|
||||||
|
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||||
|
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
|
||||||
|
"spark": {:hex, :spark, "2.6.1", "b0100216d3883c6a281cb2434af45afbd808695aadb034923cbaf7d8a2ba46ab", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "77bbefa5263bb6b70e1195bc0fc662ddb8ef5937a356a77ae072e56983ad13f0"},
|
||||||
|
"spitfire": {:hex, :spitfire, "0.3.10", "19aea9914132456515e8f7d592f63ab9f3130876b0252e834d2390bdd8becb24", [:mix], [], "hexpm", "6a6a5f77eb4165249c76199cd2d01fb595bac9207aed3de551918ac1c2bc9267"},
|
||||||
|
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
||||||
|
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
|
||||||
|
"swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"},
|
||||||
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
|
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||||
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
|
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||||
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
|
"usage_rules": {:hex, :usage_rules, "1.2.5", "3737b44ecba9fa816e04abadf5199bc78b68f05d49ae79f1fa785342167a721f", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "509eb67b7a7f90514889b602c41692b45956dc3e381ca0e25505dccd8de90390"},
|
||||||
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
"xema": {:hex, :xema, "0.17.7", "7eeda174b70a5f7fb1cc2e9fa3a7d4e78e206a99866c107d477309410b678cf2", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "7e3d7c0629282c21af6aaa5e2ba593218cd764a57bd1ae49e2c4412324e904cd"},
|
||||||
|
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||||
|
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
|
||||||
|
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
|
||||||
|
}
|
||||||
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
## `msgid`s in this file come from POT (.pot) files.
|
||||||
|
##
|
||||||
|
## Do not add, change, or remove `msgid`s manually here as
|
||||||
|
## they're tied to the ones in the corresponding POT file
|
||||||
|
## (with the same domain).
|
||||||
|
##
|
||||||
|
## Use `mix gettext.extract --merge` or `mix gettext.merge`
|
||||||
|
## to merge POT files into PO files.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Language: en\n"
|
||||||
|
|
||||||
|
## From Ecto.Changeset.cast/4
|
||||||
|
msgid "can't be blank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.unique_constraint/3
|
||||||
|
msgid "has already been taken"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.put_change/3
|
||||||
|
msgid "is invalid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_acceptance/3
|
||||||
|
msgid "must be accepted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_format/3
|
||||||
|
msgid "has invalid format"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_subset/3
|
||||||
|
msgid "has an invalid entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_exclusion/3
|
||||||
|
msgid "is reserved"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_confirmation/3
|
||||||
|
msgid "does not match confirmation"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.no_assoc_constraint/3
|
||||||
|
msgid "is still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "are still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_length/3
|
||||||
|
msgid "should have %{count} item(s)"
|
||||||
|
msgid_plural "should have %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} character(s)"
|
||||||
|
msgid_plural "should be %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} byte(s)"
|
||||||
|
msgid_plural "should be %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at least %{count} item(s)"
|
||||||
|
msgid_plural "should have at least %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} character(s)"
|
||||||
|
msgid_plural "should be at least %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} byte(s)"
|
||||||
|
msgid_plural "should be at least %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at most %{count} item(s)"
|
||||||
|
msgid_plural "should have at most %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} character(s)"
|
||||||
|
msgid_plural "should be at most %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} byte(s)"
|
||||||
|
msgid_plural "should be at most %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_number/3
|
||||||
|
msgid "must be less than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be less than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
109
priv/gettext/errors.pot
Normal file
109
priv/gettext/errors.pot
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
## This is a PO Template file.
|
||||||
|
##
|
||||||
|
## `msgid`s here are often extracted from source code.
|
||||||
|
## Add new translations manually only if they're dynamic
|
||||||
|
## translations that can't be statically extracted.
|
||||||
|
##
|
||||||
|
## Run `mix gettext.extract` to bring this file up to
|
||||||
|
## date. Leave `msgstr`s empty as changing them here has no
|
||||||
|
## effect: edit them in PO (`.po`) files instead.
|
||||||
|
## From Ecto.Changeset.cast/4
|
||||||
|
msgid "can't be blank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.unique_constraint/3
|
||||||
|
msgid "has already been taken"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.put_change/3
|
||||||
|
msgid "is invalid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_acceptance/3
|
||||||
|
msgid "must be accepted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_format/3
|
||||||
|
msgid "has invalid format"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_subset/3
|
||||||
|
msgid "has an invalid entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_exclusion/3
|
||||||
|
msgid "is reserved"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_confirmation/3
|
||||||
|
msgid "does not match confirmation"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.no_assoc_constraint/3
|
||||||
|
msgid "is still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "are still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_length/3
|
||||||
|
msgid "should have %{count} item(s)"
|
||||||
|
msgid_plural "should have %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} character(s)"
|
||||||
|
msgid_plural "should be %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} byte(s)"
|
||||||
|
msgid_plural "should be %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at least %{count} item(s)"
|
||||||
|
msgid_plural "should have at least %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} character(s)"
|
||||||
|
msgid_plural "should be at least %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} byte(s)"
|
||||||
|
msgid_plural "should be at least %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at most %{count} item(s)"
|
||||||
|
msgid_plural "should have at most %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} character(s)"
|
||||||
|
msgid_plural "should be at most %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} byte(s)"
|
||||||
|
msgid_plural "should be at most %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_number/3
|
||||||
|
msgid "must be less than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be less than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
4
priv/repo/migrations/.formatter.exs
Normal file
4
priv/repo/migrations/.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:ecto_sql],
|
||||||
|
inputs: ["*.exs"]
|
||||||
|
]
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
defmodule Mixer.Repo.Migrations.InitializeAndAddAuthenticationResourcesAndAddPasswordAuthenticationAndAddPasswordAuthAndAddMagicLinkAuthAndAddApiKeyAuthExtensions1 do
|
||||||
|
@moduledoc """
|
||||||
|
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
|
||||||
|
AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
|
||||||
|
LANGUAGE SQL
|
||||||
|
SET search_path = ''
|
||||||
|
IMMUTABLE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
|
||||||
|
AS $$ SELECT COALESCE($1, $2) $$
|
||||||
|
LANGUAGE SQL
|
||||||
|
SET search_path = ''
|
||||||
|
IMMUTABLE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN $1 IS TRUE THEN $2
|
||||||
|
ELSE $1
|
||||||
|
END $$
|
||||||
|
LANGUAGE SQL
|
||||||
|
SET search_path = ''
|
||||||
|
IMMUTABLE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN $1 IS NOT NULL THEN $2
|
||||||
|
ELSE $1
|
||||||
|
END $$
|
||||||
|
LANGUAGE SQL
|
||||||
|
SET search_path = ''
|
||||||
|
IMMUTABLE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
|
||||||
|
RETURNS text[] AS $$
|
||||||
|
DECLARE
|
||||||
|
start_index INT = 1;
|
||||||
|
end_index INT = array_length(arr, 1);
|
||||||
|
BEGIN
|
||||||
|
WHILE start_index <= end_index AND arr[start_index] = '' LOOP
|
||||||
|
start_index := start_index + 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
WHILE end_index >= start_index AND arr[end_index] = '' LOOP
|
||||||
|
end_index := end_index - 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF start_index > end_index THEN
|
||||||
|
RETURN ARRAY[]::text[];
|
||||||
|
ELSE
|
||||||
|
RETURN arr[start_index : end_index];
|
||||||
|
END IF;
|
||||||
|
END; $$
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = ''
|
||||||
|
IMMUTABLE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Raise an error with the provided JSON data.
|
||||||
|
-- The JSON object is converted to text for inclusion in the error message.
|
||||||
|
RAISE EXCEPTION 'ash_error: %', json_data::text;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SET search_path = '';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb, type_signal ANYCOMPATIBLE)
|
||||||
|
RETURNS ANYCOMPATIBLE AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Raise an error with the provided JSON data.
|
||||||
|
-- The JSON object is converted to text for inclusion in the error message.
|
||||||
|
RAISE EXCEPTION 'ash_error: %', json_data::text;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SET search_path = '';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION uuid_generate_v7()
|
||||||
|
RETURNS UUID
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
timestamp TIMESTAMPTZ;
|
||||||
|
microseconds INT;
|
||||||
|
BEGIN
|
||||||
|
timestamp = clock_timestamp();
|
||||||
|
microseconds = (cast(extract(microseconds FROM timestamp)::INT - (floor(extract(milliseconds FROM timestamp))::INT * 1000) AS DOUBLE PRECISION) * 4.096)::INT;
|
||||||
|
|
||||||
|
RETURN encode(
|
||||||
|
set_byte(
|
||||||
|
set_byte(
|
||||||
|
overlay(uuid_send(gen_random_uuid()) placing substring(int8send(floor(extract(epoch FROM timestamp) * 1000)::BIGINT) FROM 3) FROM 1 FOR 6
|
||||||
|
),
|
||||||
|
6, (b'0111' || (microseconds >> 8)::bit(4))::bit(8)::int
|
||||||
|
),
|
||||||
|
7, microseconds::bit(8)::int
|
||||||
|
),
|
||||||
|
'hex')::UUID;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
LANGUAGE PLPGSQL
|
||||||
|
SET search_path = ''
|
||||||
|
VOLATILE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
# Uncomment this if you actually want to uninstall the extensions
|
||||||
|
# when this migration is rolled back:
|
||||||
|
execute(
|
||||||
|
"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])"
|
||||||
|
)
|
||||||
|
|
||||||
|
# execute("DROP EXTENSION IF EXISTS \"citext\"")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
defmodule Mixer.Repo.Migrations.InitializeAndAddAuthenticationResourcesAndAddPasswordAuthenticationAndAddPasswordAuthAndAddMagicLinkAuthAndAddApiKeyAuth do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create table(:users, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||||
|
add :email, :citext, null: false
|
||||||
|
add :hashed_password, :text
|
||||||
|
add :confirmed_at, :utc_datetime_usec
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:users, [:email], name: "users_unique_email_index")
|
||||||
|
|
||||||
|
create table(:tokens, primary_key: false) do
|
||||||
|
add :jti, :text, null: false, primary_key: true
|
||||||
|
add :subject, :text, null: false
|
||||||
|
add :expires_at, :utc_datetime, null: false
|
||||||
|
add :purpose, :text, null: false
|
||||||
|
add :extra_data, :map
|
||||||
|
|
||||||
|
add :created_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
|
||||||
|
add :updated_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
end
|
||||||
|
|
||||||
|
create table(:api_keys, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||||
|
add :api_key_hash, :binary, null: false
|
||||||
|
add :expires_at, :utc_datetime_usec, null: false
|
||||||
|
|
||||||
|
add :user_id,
|
||||||
|
references(:users,
|
||||||
|
column: :id,
|
||||||
|
name: "api_keys_user_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:api_keys, [:api_key_hash], name: "api_keys_unique_api_key_index")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists unique_index(:api_keys, [:api_key_hash], name: "api_keys_unique_api_key_index")
|
||||||
|
|
||||||
|
drop constraint(:api_keys, "api_keys_user_id_fkey")
|
||||||
|
|
||||||
|
drop table(:api_keys)
|
||||||
|
|
||||||
|
drop table(:tokens)
|
||||||
|
|
||||||
|
drop_if_exists unique_index(:users, [:email], name: "users_unique_email_index")
|
||||||
|
|
||||||
|
drop table(:users)
|
||||||
|
end
|
||||||
|
end
|
||||||
11
priv/repo/seeds.exs
Normal file
11
priv/repo/seeds.exs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Script for populating the database. You can run it as:
|
||||||
|
#
|
||||||
|
# mix run priv/repo/seeds.exs
|
||||||
|
#
|
||||||
|
# Inside the script, you can read and write to any of your
|
||||||
|
# repositories directly:
|
||||||
|
#
|
||||||
|
# Mixer.Repo.insert!(%Mixer.SomeSchema{})
|
||||||
|
#
|
||||||
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
|
# and so on) as they will fail if something goes wrong.
|
||||||
102
priv/resource_snapshots/repo/api_keys/20260330050222.json
Normal file
102
priv/resource_snapshots/repo/api_keys/20260330050222.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "api_key_hash",
|
||||||
|
"type": "binary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "expires_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "api_keys_user_id_fkey",
|
||||||
|
"on_delete": null,
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "user_id",
|
||||||
|
"type": "uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "E38D3272E1398112D23C41485256FFB80A2B13F3253AC4CBBAD0E2B2ACB8D2F9",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "api_keys_unique_api_key_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "api_key_hash"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_api_key",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mixer.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "api_keys"
|
||||||
|
}
|
||||||
7
priv/resource_snapshots/repo/extensions.json
Normal file
7
priv/resource_snapshots/repo/extensions.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"ash_functions_version": 5,
|
||||||
|
"installed": [
|
||||||
|
"ash-functions",
|
||||||
|
"citext"
|
||||||
|
]
|
||||||
|
}
|
||||||
104
priv/resource_snapshots/repo/tokens/20260330050223.json
Normal file
104
priv/resource_snapshots/repo/tokens/20260330050223.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "jti",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "subject",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "expires_at",
|
||||||
|
"type": "utc_datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "purpose",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "extra_data",
|
||||||
|
"type": "map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "created_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "updated_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "2C8FB7EF6C2214E6BCDAE7E8AB531F7E47F6254571910413E70CBA258DABB9DE",
|
||||||
|
"identities": [],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mixer.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "tokens"
|
||||||
|
}
|
||||||
83
priv/resource_snapshots/repo/users/20260330050224.json
Normal file
83
priv/resource_snapshots/repo/users/20260330050224.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "email",
|
||||||
|
"type": "citext"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "hashed_password",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "confirmed_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "AA2B01090B10E672EC68683904A89BD990993BC71C49276CBE8716E1475289C6",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "users_unique_email_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "email"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_email",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mixer.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "users"
|
||||||
|
}
|
||||||
BIN
priv/static/favicon.ico
Normal file
BIN
priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
6
priv/static/images/logo.svg
Normal file
6
priv/static/images/logo.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
|
||||||
|
fill="#FD4F00"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
5
priv/static/robots.txt
Normal file
5
priv/static/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||||
|
#
|
||||||
|
# To ban all spiders from the entire site uncomment the next two lines:
|
||||||
|
# User-agent: *
|
||||||
|
# Disallow: /
|
||||||
14
test/mixer_web/controllers/error_html_test.exs
Normal file
14
test/mixer_web/controllers/error_html_test.exs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
defmodule MixerWeb.ErrorHTMLTest do
|
||||||
|
use MixerWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
# Bring render_to_string/4 for testing custom views
|
||||||
|
import Phoenix.Template, only: [render_to_string: 4]
|
||||||
|
|
||||||
|
test "renders 404.html" do
|
||||||
|
assert render_to_string(MixerWeb.ErrorHTML, "404", "html", []) == "Not Found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500.html" do
|
||||||
|
assert render_to_string(MixerWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
|
||||||
|
end
|
||||||
|
end
|
||||||
12
test/mixer_web/controllers/error_json_test.exs
Normal file
12
test/mixer_web/controllers/error_json_test.exs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
defmodule MixerWeb.ErrorJSONTest do
|
||||||
|
use MixerWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
test "renders 404" do
|
||||||
|
assert MixerWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500" do
|
||||||
|
assert MixerWeb.ErrorJSON.render("500.json", %{}) ==
|
||||||
|
%{errors: %{detail: "Internal Server Error"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
8
test/mixer_web/controllers/page_controller_test.exs
Normal file
8
test/mixer_web/controllers/page_controller_test.exs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
defmodule MixerWeb.PageControllerTest do
|
||||||
|
use MixerWeb.ConnCase
|
||||||
|
|
||||||
|
test "GET /", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/")
|
||||||
|
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
||||||
|
end
|
||||||
|
end
|
||||||
38
test/support/conn_case.ex
Normal file
38
test/support/conn_case.ex
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule MixerWeb.ConnCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the test case to be used by
|
||||||
|
tests that require setting up a connection.
|
||||||
|
|
||||||
|
Such tests rely on `Phoenix.ConnTest` and also
|
||||||
|
import other functionality to make it easier
|
||||||
|
to build common data structures and query the data layer.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use MixerWeb.ConnCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
# The default endpoint for testing
|
||||||
|
@endpoint MixerWeb.Endpoint
|
||||||
|
|
||||||
|
use MixerWeb, :verified_routes
|
||||||
|
|
||||||
|
# Import conveniences for testing with connections
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import MixerWeb.ConnCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
Mixer.DataCase.setup_sandbox(tags)
|
||||||
|
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||||
|
end
|
||||||
|
end
|
||||||
58
test/support/data_case.ex
Normal file
58
test/support/data_case.ex
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
defmodule Mixer.DataCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the setup for tests requiring
|
||||||
|
access to the application's data layer.
|
||||||
|
|
||||||
|
You may define functions here to be used as helpers in
|
||||||
|
your tests.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use Mixer.DataCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
alias Mixer.Repo
|
||||||
|
|
||||||
|
import Ecto
|
||||||
|
import Ecto.Changeset
|
||||||
|
import Ecto.Query
|
||||||
|
import Mixer.DataCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
Mixer.DataCase.setup_sandbox(tags)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets up the sandbox based on the test tags.
|
||||||
|
"""
|
||||||
|
def setup_sandbox(tags) do
|
||||||
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mixer.Repo, shared: not tags[:async])
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A helper that transforms changeset errors into a map of messages.
|
||||||
|
|
||||||
|
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||||
|
assert "password is too short" in errors_on(changeset).password
|
||||||
|
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def errors_on(changeset) do
|
||||||
|
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||||
|
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||||
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
2
test/test_helper.exs
Normal file
2
test/test_helper.exs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ExUnit.start()
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(Mixer.Repo, :manual)
|
||||||
Reference in New Issue
Block a user