···2828# Nix
2929result
30303131-# Dumps
3232-erl_crash.dump3131+# The directory Mix will write compiled artifacts to.
3232+/_build/
3333+3434+# If you run "mix test --cover", coverage assets end up here.
3535+/cover/
3636+3737+# The directory Mix downloads your dependencies sources to.
3838+/deps/
3939+4040+# Where 3rd-party dependencies like ExDoc output generated docs.
4141+/doc/
4242+4343+# Ignore .fetch files in case you like to edit your project deps locally.
4444+/.fetch
4545+4646+# If the VM crashes, it generates a dump, let's ignore it too.
4747+erl_crash.dump
4848+4949+# Also ignore archive artifacts (built via "mix archive.build").
5050+*.ez
5151+5252+# Temporary files, for example, from tests.
5353+/tmp/
5454+5555+# Ignore package tarball (built via "mix hex.build").
5656+comet-*.tar
5757+5858+# Ignore assets that are produced by build tools.
5959+/priv/static/assets/
6060+6161+# Ignore digested assets cache.
6262+/priv/static/cache_manifest.json
6363+6464+# In case you use Node.js/npm, you want to ignore these.
6565+npm-debug.log
6666+/assets/node_modules/
6767+
···11+This is a web application written using the Phoenix web framework.
22+33+## Project guidelines
44+55+- Use `mix precommit` alias when you are done with all changes and fix any pending issues
66+- 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
77+88+### Phoenix v1.8 guidelines
99+1010+- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
1111+- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
1212+- Anytime you run into errors with no `current_scope` assign:
1313+ - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
1414+ - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
1515+- 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
1616+- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
1717+- **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
1818+- 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
1919+custom classes must fully style the input
2020+2121+### JS and CSS guidelines
2222+2323+- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
2424+- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
2525+2626+ @import "tailwindcss" source(none);
2727+ @source "../css";
2828+ @source "../js";
2929+ @source "../../lib/my_app_web";
3030+3131+- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
3232+- **Never** use `@apply` when writing raw css
3333+- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
3434+- Out of the box **only the app.js and app.css bundles are supported**
3535+ - You cannot reference an external vendor'd script `src` or link `href` in the layouts
3636+ - You must import the vendor deps into app.js and app.css to use them
3737+ - **Never write inline <script>custom js</script> tags within templates**
3838+3939+### UI/UX & design guidelines
4040+4141+- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
4242+- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
4343+- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
4444+- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
4545+4646+4747+<!-- usage-rules-start -->
4848+4949+<!-- phoenix:elixir-start -->
5050+## Elixir guidelines
5151+5252+- Elixir lists **do not support index based access via the access syntax**
5353+5454+ **Never do this (invalid)**:
5555+5656+ i = 0
5757+ mylist = ["blue", "green"]
5858+ mylist[i]
5959+6060+ Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
6161+6262+ i = 0
6363+ mylist = ["blue", "green"]
6464+ Enum.at(mylist, i)
6565+6666+- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
6767+ 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:
6868+6969+ # INVALID: we are rebinding inside the `if` and the result never gets assigned
7070+ if connected?(socket) do
7171+ socket = assign(socket, :val, val)
7272+ end
7373+7474+ # VALID: we rebind the result of the `if` to a new variable
7575+ socket =
7676+ if connected?(socket) do
7777+ assign(socket, :val, val)
7878+ end
7979+8080+- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
8181+- **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
8282+- 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)
8383+- Don't use `String.to_atom/1` on user input (memory leak risk)
8484+- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
8585+- 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)`
8686+- 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
8787+8888+## Mix guidelines
8989+9090+- Read the docs and options before using tasks (by using `mix help task_name`)
9191+- 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`
9292+- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
9393+9494+## Test guidelines
9595+9696+- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
9797+- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
9898+ - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
9999+100100+ ref = Process.monitor(pid)
101101+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
102102+103103+ - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
104104+<!-- phoenix:elixir-end -->
105105+106106+<!-- phoenix:phoenix-start -->
107107+## Phoenix guidelines
108108+109109+- 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.
110110+111111+- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
112112+113113+ scope "/admin", AppWeb.Admin do
114114+ pipe_through :browser
115115+116116+ live "/users", UserLive, :index
117117+ end
118118+119119+ the UserLive route would point to the `AppWeb.Admin.UserLive` module
120120+121121+- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
122122+<!-- phoenix:phoenix-end -->
123123+124124+<!-- phoenix:ecto-start -->
125125+## Ecto Guidelines
126126+127127+- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
128128+- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
129129+- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
130130+- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
131131+- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
132132+- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
133133+- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
134134+<!-- phoenix:ecto-end -->
135135+136136+<!-- phoenix:html-start -->
137137+## Phoenix HTML guidelines
138138+139139+- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
140140+- **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
141141+- 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]`
142142+- **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">`)
143143+- 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)
144144+145145+- 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.
146146+147147+ **Never do this (invalid)**:
148148+149149+ <%= if condition do %>
150150+ ...
151151+ <% else if other_condition %>
152152+ ...
153153+ <% end %>
154154+155155+ Instead **always** do this:
156156+157157+ <%= cond do %>
158158+ <% condition -> %>
159159+ ...
160160+ <% condition2 -> %>
161161+ ...
162162+ <% true -> %>
163163+ ...
164164+ <% end %>
165165+166166+- 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`:
167167+168168+ <code phx-no-curly-interpolation>
169169+ let obj = {key: "val"}
170170+ </code>
171171+172172+ Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
173173+174174+- 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**:
175175+176176+ <a class={[
177177+ "px-2 text-white",
178178+ @some_flag && "py-5",
179179+ if(@other_condition, do: "border-red-500", else: "border-blue-100"),
180180+ ...
181181+ ]}>Text</a>
182182+183183+ and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
184184+185185+ and **never** do this, since it's invalid (note the missing `[` and `]`):
186186+187187+ <a class={
188188+ "px-2 text-white",
189189+ @some_flag && "py-5"
190190+ }> ...
191191+ => Raises compile syntax error on invalid HEEx attr syntax
192192+193193+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
194194+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
195195+- 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 `<%= ... %>`.
196196+197197+ **Always** do this:
198198+199199+ <div id={@id}>
200200+ {@my_assign}
201201+ <%= if @some_block_condition do %>
202202+ {@another_assign}
203203+ <% end %>
204204+ </div>
205205+206206+ and **Never** do this – the program will terminate with a syntax error:
207207+208208+ <%!-- THIS IS INVALID NEVER EVER DO THIS --%>
209209+ <div id="<%= @invalid_interpolation %>">
210210+ {if @invalid_block_construct do}
211211+ {end}
212212+ </div>
213213+<!-- phoenix:html-end -->
214214+215215+<!-- phoenix:liveview-start -->
216216+## Phoenix LiveView guidelines
217217+218218+- **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
219219+- **Avoid LiveComponent's** unless you have a strong, specific need for them
220220+- 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`
221221+222222+### LiveView streams
223223+224224+- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
225225+ - basic append of N items - `stream(socket, :messages, [new_msg])`
226226+ - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
227227+ - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
228228+ - deleting items - `stream_delete(socket, :messages, msg)`
229229+230230+- 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:
231231+232232+ <div id="messages" phx-update="stream">
233233+ <div :for={{id, msg} <- @streams.messages} id={id}>
234234+ {msg.text}
235235+ </div>
236236+ </div>
237237+238238+- 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**:
239239+240240+ def handle_event("filter", %{"filter" => filter}, socket) do
241241+ # re-fetch the messages based on the filter
242242+ messages = list_messages(filter)
243243+244244+ {:noreply,
245245+ socket
246246+ |> assign(:messages_empty?, messages == [])
247247+ # reset the stream with the new messages
248248+ |> stream(:messages, messages, reset: true)}
249249+ end
250250+251251+- 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:
252252+253253+ <div id="tasks" phx-update="stream">
254254+ <div class="hidden only:block">No tasks yet</div>
255255+ <div :for={{id, task} <- @stream.tasks} id={id}>
256256+ {task.name}
257257+ </div>
258258+ </div>
259259+260260+ The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
261261+262262+- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
263263+ along with the updated assign:
264264+265265+ def handle_event("edit_message", %{"message_id" => message_id}, socket) do
266266+ message = Chat.get_message!(message_id)
267267+ edit_form = to_form(Chat.change_message(message, %{content: message.content}))
268268+269269+ # re-insert message so @editing_message_id toggle logic takes effect for that stream item
270270+ {:noreply,
271271+ socket
272272+ |> stream_insert(:messages, message)
273273+ |> assign(:editing_message_id, String.to_integer(message_id))
274274+ |> assign(:edit_form, edit_form)}
275275+ end
276276+277277+ And in the template:
278278+279279+ <div id="messages" phx-update="stream">
280280+ <div :for={{id, message} <- @streams.messages} id={id} class="flex group">
281281+ {message.username}
282282+ <%= if @editing_message_id == message.id do %>
283283+ <%!-- Edit mode --%>
284284+ <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
285285+ ...
286286+ </.form>
287287+ <% end %>
288288+ </div>
289289+ </div>
290290+291291+- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
292292+293293+### LiveView JavaScript interop
294294+295295+- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
296296+- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
297297+298298+LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
299299+and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
300300+301301+#### Inline colocated js hooks
302302+303303+**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
304304+Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
305305+when writing scripts inside the template**:
306306+307307+ <input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
308308+ <script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
309309+ export default {
310310+ mounted() {
311311+ this.el.addEventListener("input", e => {
312312+ let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
313313+ if(match) {
314314+ this.el.value = `${match[1]}-${match[2]}-${match[3]}`
315315+ }
316316+ })
317317+ }
318318+ }
319319+ </script>
320320+321321+- colocated hooks are automatically integrated into the app.js bundle
322322+- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
323323+324324+#### External phx-hook
325325+326326+External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
327327+LiveSocket constructor:
328328+329329+ const MyHook = {
330330+ mounted() { ... }
331331+ }
332332+ let liveSocket = new LiveSocket("/live", Socket, {
333333+ hooks: { MyHook }
334334+ });
335335+336336+#### Pushing events between client and server
337337+338338+Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
339339+**Always** return or rebind the socket on `push_event/3` when pushing events:
340340+341341+ # re-bind socket so we maintain event state to be pushed
342342+ socket = push_event(socket, "my_event", %{...})
343343+344344+ # or return the modified socket directly:
345345+ def handle_event("some_event", _, socket) do
346346+ {:noreply, push_event(socket, "my_event", %{...})}
347347+ end
348348+349349+Pushed events can then be picked up in a JS hook with `this.handleEvent`:
350350+351351+ mounted() {
352352+ this.handleEvent("my_event", data => console.log("from server:", data));
353353+ }
354354+355355+Clients can also push an event to the server and receive a reply with `this.pushEvent`:
356356+357357+ mounted() {
358358+ this.el.addEventListener("click", e => {
359359+ this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
360360+ })
361361+ }
362362+363363+Where the server handled it via:
364364+365365+ def handle_event("my_event", %{"one" => 1}, socket) do
366366+ {:reply, %{two: 2}, socket}
367367+ end
368368+369369+### LiveView tests
370370+371371+- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
372372+- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
373373+- 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
374374+- **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
375375+- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
376376+- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
377377+- Focus on testing outcomes rather than implementation details
378378+- 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
379379+- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
380380+381381+ html = render(view)
382382+ document = LazyHTML.from_fragment(html)
383383+ matches = LazyHTML.filter(document, "your-complex-selector")
384384+ IO.inspect(matches, label: "Matches")
385385+386386+### Form handling
387387+388388+#### Creating a form from params
389389+390390+If you want to create a form based on `handle_event` params:
391391+392392+ def handle_event("submitted", params, socket) do
393393+ {:noreply, assign(socket, form: to_form(params))}
394394+ end
395395+396396+When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
397397+398398+You can also specify a name to nest the params:
399399+400400+ def handle_event("submitted", %{"user" => user_params}, socket) do
401401+ {:noreply, assign(socket, form: to_form(user_params, as: :user))}
402402+ end
403403+404404+#### Creating a form from changesets
405405+406406+When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
407407+408408+ defmodule MyApp.Users.User do
409409+ use Ecto.Schema
410410+ ...
411411+ end
412412+413413+And then you create a changeset that you pass to `to_form`:
414414+415415+ %MyApp.Users.User{}
416416+ |> Ecto.Changeset.change()
417417+ |> to_form()
418418+419419+Once the form is submitted, the params will be available under `%{"user" => user_params}`.
420420+421421+In the template, the form form assign can be passed to the `<.form>` function component:
422422+423423+ <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
424424+ <.input field={@form[:field]} type="text" />
425425+ </.form>
426426+427427+Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
428428+429429+#### Avoiding form errors
430430+431431+**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
432432+433433+ <%!-- ALWAYS do this (valid) --%>
434434+ <.form for={@form} id="my-form">
435435+ <.input field={@form[:field]} type="text" />
436436+ </.form>
437437+438438+And **never** do this:
439439+440440+ <%!-- NEVER do this (invalid) --%>
441441+ <.form for={@changeset} id="my-form">
442442+ <.input field={@changeset[:field]} type="text" />
443443+ </.form>
444444+445445+- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
446446+- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
447447+<!-- phoenix:liveview-end -->
448448+449449+<!-- usage-rules-end -->
···11-# The directory Mix will write compiled artifacts to.
22-/_build/
33-44-# If you run "mix test --cover", coverage assets end up here.
55-/cover/
66-77-# The directory Mix downloads your dependencies sources to.
88-/deps/
99-1010-# Where 3rd-party dependencies like ExDoc output generated docs.
1111-/doc/
1212-1313-# Ignore .fetch files in case you like to edit your project deps locally.
1414-/.fetch
1515-1616-# If the VM crashes, it generates a dump, let's ignore it too.
1717-erl_crash.dump
1818-1919-# Also ignore archive artifacts (built via "mix archive.build").
2020-*.ez
2121-2222-# Temporary files, for example, from tests.
2323-/tmp/
2424-2525-# Ignore package tarball (built via "mix hex.build").
2626-comet-*.tar
2727-
-24
apps/backend/README.md
···11-# Comet AppView
22-33-[Phoenix](https://www.phoenixframework.org)-powered AppView for Comet.
44-55----
66-77-To start your Phoenix server:
88-99-- Run `mix setup` to install and setup dependencies
1010-- Start Phoenix endpoint with `mix phx.server` or inside IEx with
1111- `iex -S mix phx.server`
1212-1313-Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
1414-1515-Ready to run in production? Please
1616-[check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
1717-1818-## Learn more
1919-2020-- Official website: https://www.phoenixframework.org/
2121-- Guides: https://hexdocs.pm/phoenix/overview.html
2222-- Docs: https://hexdocs.pm/phoenix
2323-- Forum: https://elixirforum.com/c/phoenix-forum
2424-- Source: https://github.com/phoenixframework/phoenix
-37
apps/backend/config/config.exs
···11-# This file is responsible for configuring your application
22-# and its dependencies with the aid of the Config module.
33-#
44-# This configuration file is loaded before any dependency and
55-# is restricted to this project.
66-77-# General application configuration
88-import Config
99-1010-config :comet,
1111- ecto_repos: [Comet.Repo],
1212- generators: [timestamp_type: :utc_datetime, binary_id: true]
1313-1414-config :comet, Comet.Repo, migration_primary_key: [name: :id, type: :binary_id]
1515-1616-# Configures the endpoint
1717-config :comet, CometWeb.Endpoint,
1818- url: [host: "localhost"],
1919- adapter: Bandit.PhoenixAdapter,
2020- render_errors: [
2121- formats: [json: CometWeb.ErrorJSON],
2222- layout: false
2323- ],
2424- pubsub_server: Comet.PubSub,
2525- live_view: [signing_salt: "oq2xYeBj"]
2626-2727-# Configures Elixir's Logger
2828-config :logger, :console,
2929- format: "$time $metadata[$level] $message\n",
3030- metadata: [:request_id]
3131-3232-# Use Jason for JSON parsing in Phoenix
3333-config :phoenix, :json_library, Jason
3434-3535-# Import environment specific config. This must remain at the bottom
3636-# of this file so it overrides the configuration defined above.
3737-import_config "#{config_env()}.exs"
-63
apps/backend/config/dev.exs
···11-import Config
22-33-# Configure your database
44-config :comet, Comet.Repo,
55- username: "comet",
66- password: "comet",
77- hostname: "localhost",
88- database: "comet_dev",
99- stacktrace: true,
1010- show_sensitive_data_on_connection_error: true,
1111- pool_size: 10
1212-1313-# For development, we disable any cache and enable
1414-# debugging and code reloading.
1515-#
1616-# The watchers configuration can be used to run external
1717-# watchers to your application. For example, we can use it
1818-# to bundle .js and .css sources.
1919-config :comet, CometWeb.Endpoint,
2020- # Binding to loopback ipv4 address prevents access from other machines.
2121- # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
2222- http: [ip: {127, 0, 0, 1}, port: 4000],
2323- check_origin: false,
2424- code_reloader: true,
2525- debug_errors: true,
2626- secret_key_base: "Vw9UaVO8YBKiooaOlZ2Rhx7xJHydL9s2YIviOwiiQz8Cy24+mLBB3Fj+9jvOIdQE",
2727- watchers: []
2828-2929-# ## SSL Support
3030-#
3131-# In order to use HTTPS in development, a self-signed
3232-# certificate can be generated by running the following
3333-# Mix task:
3434-#
3535-# mix phx.gen.cert
3636-#
3737-# Run `mix help phx.gen.cert` for more information.
3838-#
3939-# The `http:` config above can be replaced with:
4040-#
4141-# https: [
4242-# port: 4001,
4343-# cipher_suite: :strong,
4444-# keyfile: "priv/cert/selfsigned_key.pem",
4545-# certfile: "priv/cert/selfsigned.pem"
4646-# ],
4747-#
4848-# If desired, both `http:` and `https:` keys can be
4949-# configured to run both http and https servers on
5050-# different ports.
5151-5252-# Enable dev routes for dashboard and mailbox
5353-config :comet, dev_routes: true
5454-5555-# Do not include metadata nor timestamps in development logs
5656-config :logger, :console, format: "[$level] $message\n"
5757-5858-# Set a higher stacktrace during development. Avoid configuring such
5959-# in production as building large stacktraces may be expensive.
6060-config :phoenix, :stacktrace_depth, 20
6161-6262-# Initialize plugs at runtime for faster development compilation
6363-config :phoenix, :plug_init_mode, :runtime
-7
apps/backend/config/prod.exs
···11-import Config
22-33-# Do not print debug messages in production
44-config :logger, level: :info
55-66-# Runtime production configuration, including reading
77-# of environment variables, is done on config/runtime.exs.
···2020 config :comet, CometWeb.Endpoint, server: true
2121end
22222323+config :comet, CometWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
2424+2325if config_env() == :prod do
2426 database_url =
2527 System.get_env("DATABASE_URL") ||
···3436 # ssl: true,
3537 url: database_url,
3638 pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
3939+ # For machines with several cores, consider starting multiple pools of `pool_size`
4040+ # pool_count: 4,
3741 socket_options: maybe_ipv6
38423943 # The secret key base is used to sign/encrypt cookies and other secrets.
···4953 """
50545155 host = System.get_env("PHX_HOST") || "example.com"
5252- port = String.to_integer(System.get_env("PORT") || "4000")
53565457 config :comet, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
5558···6063 # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
6164 # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
6265 # for details about using IPv6 vs IPv4 and loopback vs public addresses.
6363- ip: {0, 0, 0, 0, 0, 0, 0, 0},
6464- port: port
6666+ ip: {0, 0, 0, 0, 0, 0, 0, 0}
6567 ],
6668 secret_key_base: secret_key_base
6769···9698 # force_ssl: [hsts: true]
9799 #
98100 # Check `Plug.SSL` for all available options in `force_ssl`.
101101+102102+ # ## Configuring the mailer
103103+ #
104104+ # In production you need to configure the mailer to use a different adapter.
105105+ # Here is an example configuration for Mailgun:
106106+ #
107107+ # config :comet, Comet.Mailer,
108108+ # adapter: Swoosh.Adapters.Mailgun,
109109+ # api_key: System.get_env("MAILGUN_API_KEY"),
110110+ # domain: System.get_env("MAILGUN_DOMAIN")
111111+ #
112112+ # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
113113+ # and Finch out-of-the-box. This configuration is typically done at
114114+ # compile-time in your config/prod.exs:
115115+ #
116116+ # config :swoosh, :api_client, Swoosh.ApiClient.Req
117117+ #
118118+ # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
99119end
+11-1
apps/backend/config/test.exs
config/test.exs
···1717# you can enable the server option below.
1818config :comet, CometWeb.Endpoint,
1919 http: [ip: {127, 0, 0, 1}, port: 4002],
2020- secret_key_base: "eaG5CrPmVserxnUlu8DyG8I6i3m3TBDOi8fsKn2niwYUMhjps0YkWWMGRnoSXvGf",
2020+ secret_key_base: "p9+pymZBaKsKSlOfPLjw9HWpolaQwJSmVaepPAdfGpv3YUp/BO5SHkaS+Faavmec",
2121 server: false
22222323+# In test we don't send emails
2424+config :comet, Comet.Mailer, adapter: Swoosh.Adapters.Test
2525+2626+# Disable swoosh api client as it is only required for production adapters
2727+config :swoosh, :api_client, false
2828+2329# Print only warnings and errors during test
2430config :logger, level: :warning
25312632# Initialize plugs at runtime for faster test compilation
2733config :phoenix, :plug_init_mode, :runtime
3434+3535+# Enable helpful, but potentially expensive runtime checks
3636+config :phoenix_live_view,
3737+ enable_expensive_runtime_checks: true
-173
apps/backend/lib/atproto/atproto.ex
···11-# AUTOGENERATED: This file was generated using the mix task `lexgen`.
22-defmodule Atproto do
33- @default_pds_hostname Application.compile_env(
44- :atproto,
55- :default_pds_hostname,
66- "https://bsky.social"
77- )
88-99- @typedoc """
1010- A type representing the names of the options that can be passed to `query/3` and `procedure/3`.
1111- """
1212- @type xrpc_opt :: :pds_hostname | :authorization
1313-1414- @typedoc """
1515- A keyword list of options that can be passed to `query/3` and `procedure/3`.
1616- """
1717- @type xrpc_opts :: [{xrpc_opt(), any()}]
1818-1919- @doc """
2020- Converts a JSON string, or decoded JSON map, into a struct based on the given module.
2121-2222- This function uses `String.to_existing_atom/1` to convert the keys of the map to atoms, meaning this will throw an error if the input JSON contains keys which are not already defined as atoms in the existing structs or codebase.
2323- """
2424- @spec decode_to_struct(module(), binary() | map()) :: map()
2525- def decode_to_struct(module, json) when is_binary(json) do
2626- decode_to_struct(module, Jason.decode!(json, keys: :atoms!))
2727- end
2828-2929- def decode_to_struct(module, map) when is_map(map) do
3030- Map.merge(module.new(), map)
3131- end
3232-3333- @doc """
3434- Raises an error if any required parameters are missing from the given map.
3535- """
3636- @spec ensure_required(map(), [String.t()]) :: map()
3737- def ensure_required(params, required) do
3838- if Enum.all?(required, fn key -> Map.has_key?(params, key) end) do
3939- params
4040- else
4141- raise ArgumentError, "Missing one or more required parameters: #{Enum.join(required, ", ")}"
4242- end
4343- end
4444-4545- @doc """
4646- Executes a "GET" HTTP request and returns the response body as a map.
4747-4848- If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used.
4949- """
5050- @spec query(map(), String.t(), xrpc_opts()) :: Req.Request.t()
5151- def query(params, target, opts \\ []) do
5252- target
5353- |> endpoint(opts)
5454- |> URI.new!()
5555- |> URI.append_query(URI.encode_query(params))
5656- |> Req.get(build_req_auth(opts))
5757- |> handle_response(opts)
5858- end
5959-6060- @doc """
6161- Executes a "POST" HTTP request and returns the response body as a map.
6262-6363- If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used.
6464- """
6565- @spec procedure(map(), String.t(), xrpc_opts()) :: {:ok | :refresh | :error, map()}
6666- def procedure(params, target, opts \\ []) do
6767- req_opts =
6868- opts
6969- |> build_req_auth()
7070- |> build_req_headers(opts, target)
7171- |> build_req_body(params, target)
7272-7373- target
7474- |> endpoint(opts)
7575- |> URI.new!()
7676- |> Req.post(req_opts)
7777- |> handle_response(opts)
7878- end
7979-8080- defp build_req_auth(opts) do
8181- case Keyword.get(opts, :access_token) do
8282- nil ->
8383- case Keyword.get(opts, :admin_token) do
8484- nil ->
8585- []
8686-8787- token ->
8888- [auth: {:basic, "admin:#{token}"}]
8989- end
9090-9191- token ->
9292- [auth: {:bearer, token}]
9393- end
9494- end
9595-9696- defp build_req_headers(req_opts, opts, "com.atproto.repo.uploadBlob") do
9797- [
9898- {:headers,
9999- [
100100- {"Content-Type", Keyword.fetch!(opts, :content_type)},
101101- {"Content-Length", Keyword.fetch!(opts, :content_length)}
102102- ]}
103103- | req_opts
104104- ]
105105- end
106106-107107- defp build_req_headers(req_opts, _opts, _target), do: req_opts
108108-109109- defp build_req_body(opts, blob, "com.atproto.repo.uploadBlob") do
110110- [{:body, blob} | opts]
111111- end
112112-113113- defp build_req_body(opts, %{} = params, _target) when map_size(params) > 0 do
114114- [{:json, params} | opts]
115115- end
116116-117117- defp build_req_body(opts, _params, _target), do: opts
118118-119119- defp endpoint(target, opts) do
120120- (Keyword.get(opts, :pds_hostname) || @default_pds_hostname) <> "/xrpc/" <> target
121121- end
122122-123123- defp handle_response({:ok, %Req.Response{} = response}, opts) do
124124- case response.status do
125125- x when x in 200..299 ->
126126- {:ok, response.body}
127127-128128- _ ->
129129- if response.body["error"] == "ExpiredToken" do
130130- {:ok, user} =
131131- Com.Atproto.Server.RefreshSession.main(%{},
132132- access_token: Keyword.get(opts, :refresh_token)
133133- )
134134-135135- {:refresh, user}
136136- else
137137- {:error, response.body}
138138- end
139139- end
140140- end
141141-142142- defp handle_response(error, _opts), do: error
143143-144144- @doc """
145145- Converts a "map-like" entity into a standard map. This will also omit any entries that have a `nil` value.
146146-147147- This is useful for converting structs or schemas into regular maps before sending them over XRPC requests.
148148-149149- You may optionally pass in an keyword list of options:
150150-151151- - `:stringify` - `boolean` - If `true`, converts the keys to strings. Otherwise, converts keys to atoms. Default is `false`.
152152- - *Note*: When `false`, this feature uses the `to_existing_atom/1` function to avoid reckless conversion of string keys.
153153- """
154154- @spec to_map(map() | struct()) :: map()
155155- def to_map(%{__struct__: _} = m, opts \\ []) do
156156- string_keys = Keyword.get(opts, :stringify, false)
157157-158158- m
159159- |> Map.drop([:__struct__, :__meta__])
160160- |> Enum.map(fn
161161- {_, nil} ->
162162- nil
163163-164164- {k, v} when is_atom(k) ->
165165- if string_keys, do: {to_string(k), v}, else: {k, v}
166166-167167- {k, v} when is_binary(k) ->
168168- if string_keys, do: {k, v}, else: {String.to_existing_atom(k), v}
169169- end)
170170- |> Enum.reject(&is_nil/1)
171171- |> Enum.into(%{})
172172- end
173173-end
···11-defmodule Sh.Comet.V0.Actor.Profile do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- A user's Comet profile.
77- """
88- @primary_key {:id, :binary_id, autogenerate: false}
99- schema "sh.comet.v0.actor.profile" do
1010- field :avatar, :map
1111- field :banner, :map
1212- field :createdAt, :utc_datetime
1313- field :description, :string
1414- field :descriptionFacets, :map
1515- field :displayName, :string
1616- field :featuredItems, {:array, :string}
1717-1818- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1919- # Ensure that you do not change this field via manual manipulation or changeset operations.
2020- field :"$type", :string, default: "sh.comet.v0.actor.profile"
2121- end
2222-2323- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2424-2525- def changeset(struct, params \\ %{}) do
2626- struct
2727- |> cast(params, [:avatar, :banner, :createdAt, :description, :descriptionFacets, :displayName, :featuredItems])
2828- |> validate_length(:featuredItems, max: 5)
2929- end
3030-end
···11-defmodule Sh.Comet.V0.Feed.Comment do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- A comment on a piece of Comet media.
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.comment" do
1010- field :createdAt, :utc_datetime
1111- field :facets, {:array, :map}
1212- field :langs, {:array, :string}
1313- field :reply, :string
1414- field :subject, :string
1515- field :text, :string
1616-1717- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1818- # Ensure that you do not change this field via manual manipulation or changeset operations.
1919- field :"$type", :string, default: "sh.comet.v0.feed.comment"
2020- end
2121-2222- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2323-2424- def changeset(struct, params \\ %{}) do
2525- struct
2626- |> cast(params, [:createdAt, :facets, :langs, :reply, :subject, :text])
2727- |> validate_required([:createdAt, :subject, :text])
2828- |> validate_length(:langs, max: 3)
2929- end
3030-end
···11-defmodule Sh.Comet.V0.Feed.Like do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- Record representing a 'like' of some media. Weakly linked with just an at-uri.
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.like" do
1010- field :createdAt, :utc_datetime
1111- field :subject, :string
1212-1313- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1414- # Ensure that you do not change this field via manual manipulation or changeset operations.
1515- field :"$type", :string, default: "sh.comet.v0.feed.like"
1616- end
1717-1818- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
1919-2020- def changeset(struct, params \\ %{}) do
2121- struct
2222- |> cast(params, [:createdAt, :subject])
2323- |> validate_required([:createdAt, :subject])
2424- end
2525-end
···11-defmodule Sh.Comet.V0.Feed.Play do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- Record representing a 'play' of some media.
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.play" do
1010- field :createdAt, :utc_datetime
1111- field :subject, :string
1212-1313- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1414- # Ensure that you do not change this field via manual manipulation or changeset operations.
1515- field :"$type", :string, default: "sh.comet.v0.feed.play"
1616- end
1717-1818- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
1919-2020- def changeset(struct, params \\ %{}) do
2121- struct
2222- |> cast(params, [:createdAt, :subject])
2323- |> validate_required([:createdAt, :subject])
2424- end
2525-end
···11-defmodule Sh.Comet.V0.Feed.Playlist do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- A Comet playlist, containing many audio tracks.
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.playlist" do
1010- field :createdAt, :utc_datetime
1111- field :description, :string
1212- field :descriptionFacets, :map
1313- field :image, :map
1414- field :link, :map
1515- field :tags, {:array, :string}
1616- field :title, :string
1717- field :type, :string
1818-1919- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
2020- # Ensure that you do not change this field via manual manipulation or changeset operations.
2121- field :"$type", :string, default: "sh.comet.v0.feed.playlist"
2222- end
2323-2424- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2525-2626- def changeset(struct, params \\ %{}) do
2727- struct
2828- |> cast(params, [:createdAt, :description, :descriptionFacets, :image, :link, :tags, :title, :type])
2929- |> validate_required([:createdAt, :title, :type])
3030- |> validate_length(:tags, max: 8)
3131- end
3232-end
···11-defmodule Sh.Comet.V0.Feed.PlaylistTrack do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- A link between a Comet track and a playlist.
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.playlistTrack" do
1010- field :playlist, :string
1111- field :position, :integer
1212- field :track, :string
1313-1414- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1515- # Ensure that you do not change this field via manual manipulation or changeset operations.
1616- field :"$type", :string, default: "sh.comet.v0.feed.playlistTrack"
1717- end
1818-1919- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2020-2121- def changeset(struct, params \\ %{}) do
2222- struct
2323- |> cast(params, [:playlist, :position, :track])
2424- |> validate_required([:playlist, :position, :track])
2525- |> validate_length(:position, min: 0)
2626- end
2727-end
···11-defmodule Sh.Comet.V0.Feed.Repost do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- Record representing a 'repost' of some media. Weakly linked with just an at-uri.
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.repost" do
1010- field :createdAt, :utc_datetime
1111- field :subject, :string
1212-1313- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1414- # Ensure that you do not change this field via manual manipulation or changeset operations.
1515- field :"$type", :string, default: "sh.comet.v0.feed.repost"
1616- end
1717-1818- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
1919-2020- def changeset(struct, params \\ %{}) do
2121- struct
2222- |> cast(params, [:createdAt, :subject])
2323- |> validate_required([:createdAt, :subject])
2424- end
2525-end
···11-defmodule Sh.Comet.V0.Feed.Track do
22- use Ecto.Schema
33- import Ecto.Changeset
44-55- @doc """
66- A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?
77- """
88- @primary_key {:id, :id, autogenerate: false}
99- schema "sh.comet.v0.feed.track" do
1010- field :audio, :map
1111- field :createdAt, :utc_datetime
1212- field :description, :string
1313- field :descriptionFacets, :map
1414- field :image, :map
1515- field :link, :map
1616- field :tags, {:array, :string}
1717- field :title, :string
1818-1919- # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
2020- # Ensure that you do not change this field via manual manipulation or changeset operations.
2121- field :"$type", :string, default: "sh.comet.v0.feed.track"
2222- end
2323-2424- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2525-2626- def changeset(struct, params \\ %{}) do
2727- struct
2828- |> cast(params, [:audio, :createdAt, :description, :descriptionFacets, :image, :link, :tags, :title])
2929- |> validate_required([:audio, :createdAt, :title])
3030- |> validate_length(:tags, max: 8)
3131- end
3232-end
···11-22-defmodule Sh.Comet.V0.Richtext.Facet.Timestamp do
33- @moduledoc """
44- Facet feature for a timestamp in a track. The text usually is in the format of 'hh:mm:ss' with the hour section being omitted if unnecessary.
55- """
66-77- @derive Jason.Encoder
88- defstruct [
99- timestamp: 0
1010- ]
1111-1212- @type t() :: %__MODULE__{
1313- timestamp: integer
1414- }
1515-1616- @spec new() :: t()
1717- def new(), do: %__MODULE__{}
1818-1919- @spec from(binary() | map()) :: t()
2020- def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
2121-end
2222-2323-defmodule Sh.Comet.V0.Richtext.Facet.Tag do
2424- @moduledoc """
2525- Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').
2626- """
2727-2828- @derive Jason.Encoder
2929- defstruct [
3030- tag: nil
3131- ]
3232-3333- @type t() :: %__MODULE__{
3434- tag: String.t()
3535- }
3636-3737- @spec new() :: t()
3838- def new(), do: %__MODULE__{}
3939-4040- @spec from(binary() | map()) :: t()
4141- def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
4242-end
4343-4444-defmodule Sh.Comet.V0.Richtext.Facet.Mention do
4545- @moduledoc """
4646- Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.
4747- """
4848-4949- @derive Jason.Encoder
5050- defstruct [
5151- did: nil
5252- ]
5353-5454- @type t() :: %__MODULE__{
5555- did: String.t()
5656- }
5757-5858- @spec new() :: t()
5959- def new(), do: %__MODULE__{}
6060-6161- @spec from(binary() | map()) :: t()
6262- def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
6363-end
6464-6565-defmodule Sh.Comet.V0.Richtext.Facet.Main do
6666- @moduledoc """
6767- Annotation of a sub-string within rich text.
6868- """
6969-7070- @derive Jason.Encoder
7171- defstruct [
7272- features: [],
7373- index: nil
7474- ]
7575-7676- @type t() :: %__MODULE__{
7777- features: list(any),
7878- index: Sh.Comet.V0.Richtext.Facet.ByteSlice.t()
7979- }
8080-8181- @spec new() :: t()
8282- def new(), do: %__MODULE__{}
8383-8484- @spec from(binary() | map()) :: t()
8585- def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
8686-end
8787-8888-defmodule Sh.Comet.V0.Richtext.Facet.Link do
8989- @moduledoc """
9090- Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.
9191- """
9292-9393- @derive Jason.Encoder
9494- defstruct [
9595- uri: nil
9696- ]
9797-9898- @type t() :: %__MODULE__{
9999- uri: String.t()
100100- }
101101-102102- @spec new() :: t()
103103- def new(), do: %__MODULE__{}
104104-105105- @spec from(binary() | map()) :: t()
106106- def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
107107-end
108108-109109-defmodule Sh.Comet.V0.Richtext.Facet.ByteSlice do
110110- @moduledoc """
111111- Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.
112112- """
113113-114114- @derive Jason.Encoder
115115- defstruct [
116116- byteEnd: 0,
117117- byteStart: 0
118118- ]
119119-120120- @type t() :: %__MODULE__{
121121- byteEnd: integer,
122122- byteStart: integer
123123- }
124124-125125- @spec new() :: t()
126126- def new(), do: %__MODULE__{}
127127-128128- @spec from(binary() | map()) :: t()
129129- def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
130130-end
131131-
-105
apps/backend/lib/atproto/tid.ex
···11-defmodule Atproto.TID do
22- @moduledoc """
33- A module for encoding and decoding TIDs.
44-55- [TID](https://atproto.com/specs/tid) stands for "Timestamp Identifier". It is a 13-character string calculated from 53 bits representing a unix timestamp, in microsecond precision, plus 10 bits for an arbitrary "clock identifier", to help with uniqueness in distributed systems.
66-77- The string is encoded as "base32-sortable", meaning that the characters for the base 32 encoding are set up in such a way that string comparisons yield the same result as integer comparisons, i.e. if the integer representation of the timestamp that creates TID "A" is greater than the integer representation of the timestamp that creates TID "B", then "A" > "B" is also true, and vice versa.
88- """
99-1010- import Bitwise
1111-1212- @tid_char_set ~c(234567abcdefghijklmnopqrstuvwxyz)
1313- @tid_char_set_length 32
1414-1515- defstruct [
1616- :timestamp,
1717- :clock_id,
1818- :string
1919- ]
2020-2121- @typedoc """
2222- TIDs are composed of two parts: a timestamp and a clock identifier. They also have a human-readable string representation as a "base32-sortable" encoded string.
2323- """
2424- @type t() :: %__MODULE__{
2525- timestamp: integer(),
2626- clock_id: integer(),
2727- string: binary()
2828- }
2929-3030- @doc """
3131- Generates a random 10-bit clock identifier.
3232- """
3333- @spec random_clock_id() :: integer()
3434- def random_clock_id(), do: :rand.uniform(1024) - 1
3535-3636- @doc """
3737- Generates a new TID for the current time.
3838-3939- This is equivalent to calling `encode(nil)`.
4040- """
4141- @spec new() :: t()
4242- def new(), do: encode(nil)
4343-4444- @doc """
4545- Encodes an integer or DateTime struct into a 13-character string that is "base32-sortable" encoded.
4646-4747- If `timestamp` is nil, or not provided, the current time will be used as represented by `DateTime.utc_now()`.
4848-4949- If `clock_id` is nil, or not provided, a random 10-bit integer will be used.
5050-5151- If `timestamp` is an integer value, it *MUST* be a unix timestamp measured in microseconds. This function does not validate integer values.
5252- """
5353- @spec encode(nil | integer() | DateTime.t(), nil | integer()) :: t()
5454- def encode(timestamp \\ nil, clock_id \\ nil)
5555-5656- def encode(nil, clock_id), do: encode(DateTime.utc_now(), clock_id)
5757-5858- def encode(timestamp, nil), do: encode(timestamp, random_clock_id())
5959-6060- def encode(%DateTime{} = datetime, clock_id) do
6161- datetime
6262- |> DateTime.to_unix(:microsecond)
6363- |> encode(clock_id)
6464- end
6565-6666- def encode(timestamp, clock_id) when is_integer(timestamp) and is_integer(clock_id) do
6767- # Ensure we only use the lower 10 bit of clock_id
6868- clock_id = clock_id &&& 1023
6969- str =
7070- timestamp
7171- |> bsr(10)
7272- |> bsl(10)
7373- |> bxor(clock_id)
7474- |> do_encode("")
7575- %__MODULE__{timestamp: timestamp, clock_id: clock_id, string: str}
7676- end
7777-7878- defp do_encode(0, acc), do: acc
7979-8080- defp do_encode(number, acc) do
8181- c = rem(number, @tid_char_set_length)
8282- number = div(number, @tid_char_set_length)
8383- do_encode(number, <<Enum.at(@tid_char_set, c)>> <> acc)
8484- end
8585-8686- @doc """
8787- Decodes a binary string into a TID struct.
8888- """
8989- @spec decode(binary()) :: t()
9090- def decode(tid) do
9191- num = do_decode(tid, 0)
9292- %__MODULE__{timestamp: bsr(num, 10), clock_id: num &&& 1023, string: tid}
9393- end
9494-9595- defp do_decode(<<>>, acc), do: acc
9696-9797- defp do_decode(<<char::utf8, rest::binary>>, acc) do
9898- idx = Enum.find_index(@tid_char_set, fn x -> x == char end)
9999- do_decode(rest, (acc * @tid_char_set_length) + idx)
100100- end
101101-end
102102-103103-defimpl String.Chars, for: Atproto.TID do
104104- def to_string(tid), do: tid.string
105105-end
···11-defmodule Comet.AtURI do
22- use TypedStruct
33-44- @did "did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
55- @handle "(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
66- @nsid "[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)"
77-88- @authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
99- @collection "(?<collection>#{@nsid})"
1010- @rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
1111-1212- @re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
1313-1414- typedstruct do
1515- field :authority, String.t(), enforce: true
1616- field :collection, String.t() | nil
1717- field :rkey, String.t() | nil
1818- end
1919-2020- @spec new(String.t()) :: {:ok, t()} | :error
2121- def new(string) when is_binary(string) do
2222- case Regex.named_captures(@re, string) do
2323- %{} = captures -> {:ok, from_named_captures(captures)}
2424- nil -> :error
2525- end
2626- end
2727-2828- @spec new!(String.t()) :: t()
2929- def new!(string) when is_binary(string) do
3030- case new(string) do
3131- {:ok, uri} -> uri
3232- :error -> raise ArgumentError, message: "Malformed at:// URI"
3333- end
3434- end
3535-3636- @spec match?(String.t()) :: boolean()
3737- def match?(string), do: Regex.match?(@re, string)
3838-3939- @spec to_string(t()) :: String.t()
4040- def to_string(%__MODULE__{} = uri) do
4141- "at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
4242- |> String.trim_trailing("/")
4343- end
4444-4545- defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
4646- do: %__MODULE__{authority: authority}
4747-4848- defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
4949- do: %__MODULE__{authority: authority, collection: collection}
5050-5151- defp from_named_captures(%{
5252- "authority" => authority,
5353- "collection" => collection,
5454- "rkey" => rkey
5555- }),
5656- do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
5757-end
5858-5959-defimpl String.Chars, for: Comet.AtURI do
6060- def to_string(%Comet.AtURI{} = uri), do: Comet.AtURI.to_string(uri)
6161-end
apps/backend/lib/comet/repo.ex
lib/comet/repo.ex
-31
apps/backend/lib/comet/repo/comment.ex
···11-defmodule Comet.Repo.Comment do
22- @moduledoc """
33- Schema containing information about a Comet comment.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- schema "comments" do
99- field :rkey, :string
1010- field :text, :string
1111- embeds_one :facets, Repo.Embed.Facet, on_replace: :update
1212- field :subject_id, :binary_id
1313- field :subject_type, Ecto.Enum, values: [:track, :playlist]
1414- field :langs, {:array, :string}
1515- field :created_at, :utc_datetime
1616-1717- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
1818- belongs_to :parent, __MODULE__, foreign_key: :reply_id
1919- has_many :replies, __MODULE__, foreign_key: :reply_id
2020-2121- timestamps(inserted_at: :indexed_at, updated_at: false)
2222- end
2323-2424- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2525-2626- def changeset(struct, params \\ %{}) do
2727- struct
2828- |> cast(params, [:rkey, :did, :text, :facets, :subject_id, :subject_type, :langs, :created_at])
2929- |> validate_required([:rkey, :text])
3030- end
3131-end
-22
apps/backend/lib/comet/repo/embed/facet.ex
···11-defmodule Comet.Repo.Embed.Facet do
22- use Comet.Schema
33- import Ecto.Changeset
44-55- @primary_key false
66- embedded_schema do
77- embeds_one :index, ByteSlice do
88- field :byte_start, :integer
99- field :byte_end, :integer
1010- end
1111-1212- # Sadly Ecto doesn't support union types/embeds so this has to be generic, without doing weirdness in the database at least
1313- field :features, {:array, :map}
1414- end
1515-1616- def changeset(struct, params \\ %{}) do
1717- struct
1818- |> cast(params, [:features])
1919- |> cast_embed(:index, required: true)
2020- |> validate_required([:features])
2121- end
2222-end
-16
apps/backend/lib/comet/repo/embed/link.ex
···11-defmodule Comet.Repo.Embed.Link do
22- use Comet.Schema
33- import Ecto.Changeset
44-55- @primary_key false
66- embedded_schema do
77- field :type, :string
88- field :value, :string
99- end
1010-1111- def changeset(struct, params \\ %{}) do
1212- struct
1313- |> cast(params, [:type, :value])
1414- |> validate_required([:type, :value])
1515- end
1616-end
-26
apps/backend/lib/comet/repo/identity.ex
···11-defmodule Comet.Repo.Identity do
22- @moduledoc """
33- Schema containing information about an ATProtocol identity.
44- """
55- use Ecto.Schema
66- import Ecto.Changeset
77-88- @primary_key {:did, :string, autogenerate: false}
99- @foreign_key_type :string
1010-1111- schema "identity" do
1212- field :handle, :string
1313- field :active, :boolean
1414- field :status, :string
1515-1616- timestamps(inserted_at: :indexed_at, updated_at: false)
1717- end
1818-1919- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2020-2121- def changeset(struct, params \\ %{}) do
2222- struct
2323- |> cast(params, [:did, :handle, :active, :status])
2424- |> validate_required([:did, :active])
2525- end
2626-end
-26
apps/backend/lib/comet/repo/like.ex
···11-defmodule Comet.Repo.Like do
22- @moduledoc """
33- Schema containing information about a Comet like.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- schema "likes" do
99- field :rkey, :string
1010- field :subject_id, :binary_id
1111- field :subject_type, Ecto.Enum, values: [:track, :playlist]
1212- field :created_at, :utc_datetime
1313-1414- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
1515-1616- timestamps(inserted_at: :indexed_at, updated_at: false)
1717- end
1818-1919- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2020-2121- def changeset(struct, params \\ %{}) do
2222- struct
2323- |> cast(params, [:rkey, :did, :subject_id, :subject_type, :created_at])
2424- |> validate_required([:rkey, :did, :subject_id, :subject_type, :created_at])
2525- end
2626-end
-35
apps/backend/lib/comet/repo/playlist.ex
···11-defmodule Comet.Repo.Playlist do
22- @moduledoc """
33- Sch ema containing information about a Comet playlist.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- schema "playlists" do
99- field :rkey, :string
1010- field :title, :string
1111- field :image, :string
1212- field :description, :string
1313- # TODO: see how this looks with/without primary id
1414- embeds_one :description_facets, Repo.Embed.Facet, on_replace: :update
1515- field :type, :string
1616- field :tags, {:array, :string}
1717- embeds_one :link, Repo.Embed.Link, on_replace: :update
1818- field :created_at, :utc_datetime
1919-2020- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
2121- has_many :tracks, Repo.Playlist
2222-2323- timestamps(inserted_at: :indexed_at, updated_at: false)
2424- end
2525-2626- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2727-2828- def changeset(struct, params \\ %{}) do
2929- struct
3030- |> cast(params, [:rkey, :did, :title, :image, :description, :type, :tags, :created_at])
3131- |> cast_embed(:description_facets)
3232- |> cast_embed(:link)
3333- |> validate_required([:rkey, :did, :title, :type, :created_at])
3434- end
3535-end
-27
apps/backend/lib/comet/repo/playlist_track.ex
···11-defmodule Comet.Repo.PlaylistTrack do
22- @moduledoc """
33- Schema containing information about a track in a Comet playlist.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- schema "playlist_tracks" do
99- field :rkey, :string
1010- field :position, :integer
1111- field :created_at, :utc_datetime
1212-1313- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
1414- belongs_to :track, Repo.Track
1515- belongs_to :playlist, Repo.Playlist
1616-1717- timestamps(inserted_at: :indexed_at, updated_at: false)
1818- end
1919-2020- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2121-2222- def changeset(struct, params \\ %{}) do
2323- struct
2424- |> cast(params, [:rkey, :did, :position, :created_at, :track_id, :playlist_id])
2525- |> validate_required([:rkey, :did, :position, :created_at, :track_id, :playlist_id])
2626- end
2727-end
-41
apps/backend/lib/comet/repo/profile.ex
···11-defmodule Comet.Repo.Profile do
22- @moduledoc """
33- Schema containing information about a Comet profile.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- # TODO: should probably keep track of CID so as to not do unnecessary writes
99- schema "profiles" do
1010- field :rkey, :string, default: "self"
1111- field :display_name, :string
1212- field :description, :string
1313- embeds_one :description_facets, Repo.Embed.Facet, on_replace: :update
1414- field :avatar, :string
1515- field :banner, :string
1616- field :featured_items, {:array, :string}
1717- field :created_at, :utc_datetime
1818-1919- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
2020-2121- timestamps(inserted_at: :indexed_at, updated_at: false)
2222- end
2323-2424- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2525-2626- def changeset(struct, params \\ %{}) do
2727- struct
2828- |> cast(params, [
2929- :rkey,
3030- :did,
3131- :display_name,
3232- :description,
3333- :avatar,
3434- :banner,
3535- :featured_items,
3636- :created_at
3737- ])
3838- |> cast_embed(:description_facets)
3939- |> validate_required([:rkey, :did])
4040- end
4141-end
-26
apps/backend/lib/comet/repo/repost.ex
···11-defmodule Comet.Repo.Repost do
22- @moduledoc """
33- Schema containing information about a Comet repost.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- schema "reposts" do
99- field :rkey, :string
1010- field :subject_id, :binary_id
1111- field :subject_type, Ecto.Enum, values: [:track, :playlist]
1212- field :created_at, :utc_datetime
1313-1414- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
1515-1616- timestamps(inserted_at: :indexed_at, updated_at: false)
1717- end
1818-1919- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2020-2121- def changeset(struct, params \\ %{}) do
2222- struct
2323- |> cast(params, [:rkey, :did, :subject_id, :subject_type, :created_at])
2424- |> validate_required([:rkey, :did, :subject_id, :subject_type, :created_at])
2525- end
2626-end
-46
apps/backend/lib/comet/repo/track.ex
···11-defmodule Comet.Repo.Track do
22- @moduledoc """
33- Schema containing information about a Comet track.
44- """
55- use Comet.Schema
66- import Ecto.Changeset
77-88- schema "tracks" do
99- field :rkey, :string
1010- field :title, :string
1111- field :audio, :string
1212- field :image, :string
1313- field :description, :string
1414- embeds_one :description_facets, Repo.Embed.Facet, on_replace: :update
1515- field :explicit, :boolean
1616- field :tags, {:array, :string}
1717- embeds_one :link, Repo.Embed.Link, on_replace: :update
1818- field :created_at, :utc_datetime
1919- field :released_at, :utc_datetime
2020-2121- belongs_to :identity, Repo.Identity, foreign_key: :did, references: :did
2222-2323- timestamps(inserted_at: :indexed_at, updated_at: false)
2424- end
2525-2626- def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2727-2828- def changeset(struct, params \\ %{}) do
2929- struct
3030- |> cast(params, [
3131- :rkey,
3232- :did,
3333- :title,
3434- :audio,
3535- :image,
3636- :description,
3737- :explicit,
3838- :tags,
3939- :created_at,
4040- :released_at
4141- ])
4242- |> cast_embed(:description_facets)
4343- |> cast_embed(:link)
4444- |> validate_required([:rkey, :did, :audio, :title, :created_at])
4545- end
4646-end
-11
apps/backend/lib/comet/schema.ex
···11-defmodule Comet.Schema do
22- defmacro __using__(_) do
33- quote do
44- use Ecto.Schema
55- alias Comet.Repo
66-77- @primary_key {:id, :binary_id, autogenerate: true}
88- @foreign_key_type :binary_id
99- end
1010- end
1111-end
-65
apps/backend/lib/comet_web.ex
···11-defmodule CometWeb do
22- @moduledoc """
33- The entrypoint for defining your web interface, such
44- as controllers, components, channels, and so on.
55-66- This can be used in your application as:
77-88- use CometWeb, :controller
99- use CometWeb, :html
1010-1111- The definitions below will be executed for every controller,
1212- component, etc, so keep them short and clean, focused
1313- on imports, uses and aliases.
1414-1515- Do NOT define functions inside the quoted expressions
1616- below. Instead, define additional modules and import
1717- those modules here.
1818- """
1919-2020- def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
2121-2222- def router do
2323- quote do
2424- use Phoenix.Router, helpers: false
2525-2626- # Import common connection and controller functions to use in pipelines
2727- import Plug.Conn
2828- import Phoenix.Controller
2929- end
3030- end
3131-3232- def channel do
3333- quote do
3434- use Phoenix.Channel
3535- end
3636- end
3737-3838- def controller do
3939- quote do
4040- use Phoenix.Controller,
4141- formats: [:html, :json],
4242- layouts: [html: CometWeb.Layouts]
4343-4444- import Plug.Conn
4545-4646- unquote(verified_routes())
4747- end
4848- end
4949-5050- def verified_routes do
5151- quote do
5252- use Phoenix.VerifiedRoutes,
5353- endpoint: CometWeb.Endpoint,
5454- router: CometWeb.Router,
5555- statics: CometWeb.static_paths()
5656- end
5757- end
5858-5959- @doc """
6060- When used, dispatch to the appropriate controller/live_view/etc.
6161- """
6262- defmacro __using__(which) when is_atom(which) do
6363- apply(__MODULE__, which, [])
6464- end
6565-end
···77 @session_options [
88 store: :cookie,
99 key: "_comet_key",
1010- signing_salt: "zgKytneJ",
1010+ signing_salt: "a/yCy9X7",
1111 same_site: "Lax"
1212 ]
1313···17171818 # Serve at "/" the static files from "priv/static" directory.
1919 #
2020- # You should set gzip to true if you are running phx.digest
2121- # when deploying your static files in production.
2020+ # When code reloading is disabled (e.g., in production),
2121+ # the `gzip` option is enabled to serve compressed
2222+ # static files generated by running `phx.digest`.
2223 plug Plug.Static,
2324 at: "/",
2425 from: :comet,
2525- gzip: false,
2626- only: CometWeb.static_paths()
2626+ gzip: not code_reloading?,
2727+ only: CometWeb.static_paths(),
2828+ raise_on_missing_only: code_reloading?
27292830 # Code reloading can be explicitly enabled under the
2931 # :code_reloader configuration of your endpoint.
3032 if code_reloading? do
3333+ socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
3434+ plug Phoenix.LiveReloader
3135 plug Phoenix.CodeReloader
3236 plug Phoenix.Ecto.CheckRepoStatus, otp_app: :comet
3337 end
-27
apps/backend/lib/comet_web/router.ex
···11-defmodule CometWeb.Router do
22- use CometWeb, :router
33-44- pipeline :api do
55- plug :accepts, ["json"]
66- end
77-88- scope "/api", CometWeb do
99- pipe_through :api
1010- end
1111-1212- # Enable LiveDashboard in development
1313- if Application.compile_env(:comet, :dev_routes) do
1414- # If you want to use the LiveDashboard in production, you should put
1515- # it behind authentication and allow only admins to access it.
1616- # If your application does not have an admins-only section yet,
1717- # you can use Plug.BasicAuth to set up some basic authentication
1818- # as long as you are also using SSL (which you should anyway).
1919- import Phoenix.LiveDashboard.Router
2020-2121- scope "/dev" do
2222- pipe_through [:fetch_session, :protect_from_forgery]
2323-2424- live_dashboard "/dashboard", metrics: CometWeb.Telemetry
2525- end
2626- end
2727-end
···11-// place files you want to import through the `$lib` alias in this folder.
-15
apps/frontend/src/routes/+layout.svelte
···11-<script lang="ts">
22- import Navbar from "$lib/components/Navbar.svelte";
33- import Player1 from "$lib/components/Player1.svelte";
44- import Player2 from "$lib/components/Player2.svelte";
55- import "../app.css";
66-77- let { children } = $props();
88-</script>
99-1010-<Navbar />
1111-<main class="m-2 flex flex-col items-center">
1212- {@render children()}
1313-</main>
1414-<!-- <Player1 /> -->
1515-<Player2 />
-32
apps/frontend/src/routes/+page.svelte
···11-<section class="flex flex-col items-center gap-2 pt-10">
22- <header class="flex flex-col items-center">
33- <h1 class="text-5xl font-bold tracking-tighter text-orange-600">Comet</h1>
44- <p class="flex flex-col items-center text-center text-lg">
55- Your music, on ATProto.
66- </p>
77- </header>
88-99- <div>
1010- <h2 class="text-2xl font-bold tracking-tighter text-orange-600">Why?</h2>
1111- <ol class="list-disc">
1212- <li>
1313- free yourself from Big Tech™️; don't let them tell you what music you
1414- can and can't make
1515- </li>
1616- <li>
1717- Listen to music in full* quality, without being subject to horrendous
1818- sounding data compression.
1919- </li>
2020- <li>
2121- if I die, all your data is yours, and can be used by other projects!
2222- </li>
2323- <li>
2424- Integrate your playback with <span class="text-teal-800 underline">
2525- teal.fm
2626- </span>
2727- and let everyone else know what you're listening to!
2828- </li>
2929- <li>borpa</li>
3030- </ol>
3131- </div>
3232-</section>
apps/frontend/static/favicon.png
This is a binary file and will not be displayed.
-18
apps/frontend/svelte.config.js
···11-import adapter from "@sveltejs/adapter-auto";
22-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
33-44-/** @type {import('@sveltejs/kit').Config} */
55-const config = {
66- // Consult https://svelte.dev/docs/kit/integrations
77- // for more information about preprocessors
88- preprocess: vitePreprocess(),
99-1010- kit: {
1111- // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
1212- // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
1313- // See https://svelte.dev/docs/kit/adapters for more information about adapters.
1414- adapter: adapter(),
1515- },
1616-};
1717-1818-export default config;
-19
apps/frontend/tsconfig.json
···11-{
22- "extends": "./.svelte-kit/tsconfig.json",
33- "compilerOptions": {
44- "allowJs": true,
55- "checkJs": true,
66- "esModuleInterop": true,
77- "forceConsistentCasingInFileNames": true,
88- "resolveJsonModule": true,
99- "skipLibCheck": true,
1010- "sourceMap": true,
1111- "strict": true,
1212- "moduleResolution": "bundler"
1313- }
1414- // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1515- // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1616- //
1717- // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
1818- // from the referenced tsconfig.json - TypeScript does not merge them in
1919-}
-7
apps/frontend/vite.config.ts
···11-import tailwindcss from "@tailwindcss/vite";
22-import { sveltekit } from "@sveltejs/kit/vite";
33-import { defineConfig } from "vite";
44-55-export default defineConfig({
66- plugins: [tailwindcss(), sveltekit()],
77-});
+24
assets/css/app.css
···11+/* See the Tailwind configuration guide for advanced usage
22+ https://tailwindcss.com/docs/configuration */
33+44+@import "tailwindcss" source(none);
55+@source "../css";
66+@source "../js";
77+@source "../../lib/comet_web";
88+99+/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
1010+ The heroicons installation itself is managed by your mix.exs */
1111+@plugin "../vendor/heroicons";
1212+1313+/* Add variants based on LiveView classes */
1414+@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
1515+@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
1616+@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
1717+1818+/* Use the data attribute for dark mode */
1919+@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
2020+2121+/* Make LiveView wrapper divs transparent for layout */
2222+[data-phx-session], [data-phx-teleported-src] { display: contents }
2323+2424+/* This file is for your main application CSS */
+83
assets/js/app.js
···11+// If you want to use Phoenix channels, run `mix help phx.gen.channel`
22+// to get started and then uncomment the line below.
33+// import "./user_socket.js"
44+55+// You can include dependencies in two ways.
66+//
77+// The simplest option is to put them in assets/vendor and
88+// import them using relative paths:
99+//
1010+// import "../vendor/some-package.js"
1111+//
1212+// Alternatively, you can `npm install some-package --prefix assets` and import
1313+// them using a path starting with the package name:
1414+//
1515+// import "some-package"
1616+//
1717+// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
1818+// To load it, simply add a second `<link>` to your `root.html.heex` file.
1919+2020+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
2121+import "phoenix_html"
2222+// Establish Phoenix Socket and LiveView configuration.
2323+import {Socket} from "phoenix"
2424+import {LiveSocket} from "phoenix_live_view"
2525+import {hooks as colocatedHooks} from "phoenix-colocated/comet"
2626+import topbar from "../vendor/topbar"
2727+2828+const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
2929+const liveSocket = new LiveSocket("/live", Socket, {
3030+ longPollFallbackMs: 2500,
3131+ params: {_csrf_token: csrfToken},
3232+ hooks: {...colocatedHooks},
3333+})
3434+3535+// Show progress bar on live navigation and form submits
3636+topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
3737+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
3838+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
3939+4040+// connect if there are any LiveViews on the page
4141+liveSocket.connect()
4242+4343+// expose liveSocket on window for web console debug logs and latency simulation:
4444+// >> liveSocket.enableDebug()
4545+// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
4646+// >> liveSocket.disableLatencySim()
4747+window.liveSocket = liveSocket
4848+4949+// The lines below enable quality of life phoenix_live_reload
5050+// development features:
5151+//
5252+// 1. stream server logs to the browser console
5353+// 2. click on elements to jump to their definitions in your code editor
5454+//
5555+if (process.env.NODE_ENV === "development") {
5656+ window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
5757+ // Enable server log streaming to client.
5858+ // Disable with reloader.disableServerLogs()
5959+ reloader.enableServerLogs()
6060+6161+ // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
6262+ //
6363+ // * click with "c" key pressed to open at caller location
6464+ // * click with "d" key pressed to open at function component definition location
6565+ let keyDown
6666+ window.addEventListener("keydown", e => keyDown = e.key)
6767+ window.addEventListener("keyup", _e => keyDown = null)
6868+ window.addEventListener("click", e => {
6969+ if(keyDown === "c"){
7070+ e.preventDefault()
7171+ e.stopImmediatePropagation()
7272+ reloader.openEditorAtCaller(e.target)
7373+ } else if(keyDown === "d"){
7474+ e.preventDefault()
7575+ e.stopImmediatePropagation()
7676+ reloader.openEditorAtDef(e.target)
7777+ }
7878+ }, true)
7979+8080+ window.liveReloader = reloader
8181+ })
8282+}
8383+
+32
assets/tsconfig.json
···11+// This file is needed on most editors to enable the intelligent autocompletion
22+// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
33+//
44+// Note: This file assumes a basic esbuild setup without node_modules.
55+// We include a generic paths alias to deps to mimic how esbuild resolves
66+// the Phoenix and LiveView JavaScript assets.
77+// If you have a package.json in your project, you should remove the
88+// paths configuration and instead add the phoenix dependencies to the
99+// dependencies section of your package.json:
1010+//
1111+// {
1212+// ...
1313+// "dependencies": {
1414+// ...,
1515+// "phoenix": "../deps/phoenix",
1616+// "phoenix_html": "../deps/phoenix_html",
1717+// "phoenix_live_view": "../deps/phoenix_live_view"
1818+// }
1919+// }
2020+//
2121+// Feel free to adjust this configuration however you need.
2222+{
2323+ "compilerOptions": {
2424+ "baseUrl": ".",
2525+ "paths": {
2626+ "*": ["../deps/*"]
2727+ },
2828+ "allowJs": true,
2929+ "noEmit": true
3030+ },
3131+ "include": ["js/**/*"]
3232+}
···11+# This file is responsible for configuring your application
22+# and its dependencies with the aid of the Config module.
33+#
44+# This configuration file is loaded before any dependency and
55+# is restricted to this project.
66+77+# General application configuration
88+import Config
99+1010+config :comet,
1111+ ecto_repos: [Comet.Repo],
1212+ generators: [timestamp_type: :utc_datetime, binary_id: true]
1313+1414+# Configure the endpoint
1515+config :comet, CometWeb.Endpoint,
1616+ url: [host: "localhost"],
1717+ adapter: Bandit.PhoenixAdapter,
1818+ render_errors: [
1919+ formats: [html: CometWeb.ErrorHTML, json: CometWeb.ErrorJSON],
2020+ layout: false
2121+ ],
2222+ pubsub_server: Comet.PubSub,
2323+ live_view: [signing_salt: "ObastmTN"]
2424+2525+# Configure the mailer
2626+#
2727+# By default it uses the "Local" adapter which stores the emails
2828+# locally. You can see the emails in your browser, at "/dev/mailbox".
2929+#
3030+# For production it's recommended to configure a different adapter
3131+# at the `config/runtime.exs`.
3232+config :comet, Comet.Mailer, adapter: Swoosh.Adapters.Local
3333+3434+# Configure esbuild (the version is required)
3535+config :esbuild,
3636+ version: "0.25.4",
3737+ comet: [
3838+ args:
3939+ ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
4040+ cd: Path.expand("../assets", __DIR__),
4141+ env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
4242+ ]
4343+4444+# Configure tailwind (the version is required)
4545+config :tailwind,
4646+ version: "4.1.12",
4747+ comet: [
4848+ args: ~w(
4949+ --input=assets/css/app.css
5050+ --output=priv/static/assets/css/app.css
5151+ ),
5252+ cd: Path.expand("..", __DIR__)
5353+ ],
5454+ version_check: false,
5555+ path: System.get_env("TAILWINDCSS_PATH", Path.expand("../assets/node_modules/.bin/tailwindcss", __DIR__))
5656+5757+# Configure Elixir's Logger
5858+config :logger, :default_formatter,
5959+ format: "$time $metadata[$level] $message\n",
6060+ metadata: [:request_id]
6161+6262+# Use Jason for JSON parsing in Phoenix
6363+config :phoenix, :json_library, Jason
6464+6565+# Import environment specific config. This must remain at the bottom
6666+# of this file so it overrides the configuration defined above.
6767+import_config "#{config_env()}.exs"
+92
config/dev.exs
···11+import Config
22+33+# Configure your database
44+config :comet, Comet.Repo,
55+ username: "postgres",
66+ password: "postgres",
77+ hostname: "localhost",
88+ database: "comet_dev",
99+ stacktrace: true,
1010+ show_sensitive_data_on_connection_error: true,
1111+ pool_size: 10
1212+1313+# For development, we disable any cache and enable
1414+# debugging and code reloading.
1515+#
1616+# The watchers configuration can be used to run external
1717+# watchers to your application. For example, we can use it
1818+# to bundle .js and .css sources.
1919+config :comet, CometWeb.Endpoint,
2020+ # Binding to loopback ipv4 address prevents access from other machines.
2121+ # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
2222+ http: [ip: {127, 0, 0, 1}],
2323+ check_origin: false,
2424+ code_reloader: true,
2525+ debug_errors: true,
2626+ secret_key_base: "qEVbyaHJ1+52Mfh7Kfoc94yEwNc/e5vkRDUcAR4b3DofYJg7LgUjm4kd+3u+RelM",
2727+ watchers: [
2828+ esbuild: {Esbuild, :install_and_run, [:comet, ~w(--sourcemap=inline --watch)]},
2929+ tailwind: {Tailwind, :install_and_run, [:comet, ~w(--watch)]}
3030+ ]
3131+3232+# ## SSL Support
3333+#
3434+# In order to use HTTPS in development, a self-signed
3535+# certificate can be generated by running the following
3636+# Mix task:
3737+#
3838+# mix phx.gen.cert
3939+#
4040+# Run `mix help phx.gen.cert` for more information.
4141+#
4242+# The `http:` config above can be replaced with:
4343+#
4444+# https: [
4545+# port: 4001,
4646+# cipher_suite: :strong,
4747+# keyfile: "priv/cert/selfsigned_key.pem",
4848+# certfile: "priv/cert/selfsigned.pem"
4949+# ],
5050+#
5151+# If desired, both `http:` and `https:` keys can be
5252+# configured to run both http and https servers on
5353+# different ports.
5454+5555+# Reload browser tabs when matching files change.
5656+config :comet, CometWeb.Endpoint,
5757+ live_reload: [
5858+ web_console_logger: true,
5959+ patterns: [
6060+ # Static assets, except user uploads
6161+ ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
6262+ # Gettext translations
6363+ ~r"priv/gettext/.*\.po$",
6464+ # Router, Controllers, LiveViews and LiveComponents
6565+ ~r"lib/comet_web/router\.ex$",
6666+ ~r"lib/comet_web/(controllers|live|components)/.*\.(ex|heex)$"
6767+ ]
6868+ ]
6969+7070+# Enable dev routes for dashboard and mailbox
7171+config :comet, dev_routes: true
7272+7373+# Do not include metadata nor timestamps in development logs
7474+config :logger, :default_formatter, format: "[$level] $message\n"
7575+7676+# Set a higher stacktrace during development. Avoid configuring such
7777+# in production as building large stacktraces may be expensive.
7878+config :phoenix, :stacktrace_depth, 20
7979+8080+# Initialize plugs at runtime for faster development compilation
8181+config :phoenix, :plug_init_mode, :runtime
8282+8383+config :phoenix_live_view,
8484+ # Include debug annotations and locations in rendered markup.
8585+ # Changing this configuration will require mix clean and a full recompile.
8686+ debug_heex_annotations: true,
8787+ debug_attributes: true,
8888+ # Enable helpful, but potentially expensive runtime checks
8989+ enable_expensive_runtime_checks: true
9090+9191+# Disable swoosh api client as it is only required for production adapters.
9292+config :swoosh, :api_client, false
+24
config/prod.exs
···11+import Config
22+33+# Note we also include the path to a cache manifest
44+# containing the digested version of static files. This
55+# manifest is generated by the `mix assets.deploy` task,
66+# which you should run after static files are built and
77+# before starting your production server.
88+config :comet, CometWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
99+1010+# Force using SSL in production. This also sets the "strict-security-transport" header,
1111+# also known as HSTS. `:force_ssl` is required to be set at compile-time.
1212+config :comet, CometWeb.Endpoint, force_ssl: [rewrite_on: [:x_forwarded_proto]]
1313+1414+# Configure Swoosh API Client
1515+config :swoosh, api_client: Swoosh.ApiClient.Req
1616+1717+# Disable Swoosh Local Memory Storage
1818+config :swoosh, local: false
1919+2020+# Do not print debug messages in production
2121+config :logger, level: :info
2222+2323+# Runtime production configuration, including reading
2424+# of environment variables, is done on config/runtime.exs.
···11+defmodule Comet.Mailer do
22+ use Swoosh.Mailer, otp_app: :comet
33+end
+114
lib/comet_web.ex
···11+defmodule CometWeb do
22+ @moduledoc """
33+ The entrypoint for defining your web interface, such
44+ as controllers, components, channels, and so on.
55+66+ This can be used in your application as:
77+88+ use CometWeb, :controller
99+ use CometWeb, :html
1010+1111+ The definitions below will be executed for every controller,
1212+ component, etc, so keep them short and clean, focused
1313+ on imports, uses and aliases.
1414+1515+ Do NOT define functions inside the quoted expressions
1616+ below. Instead, define additional modules and import
1717+ those modules here.
1818+ """
1919+2020+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
2121+2222+ def router do
2323+ quote do
2424+ use Phoenix.Router, helpers: false
2525+2626+ # Import common connection and controller functions to use in pipelines
2727+ import Plug.Conn
2828+ import Phoenix.Controller
2929+ import Phoenix.LiveView.Router
3030+ end
3131+ end
3232+3333+ def channel do
3434+ quote do
3535+ use Phoenix.Channel
3636+ end
3737+ end
3838+3939+ def controller do
4040+ quote do
4141+ use Phoenix.Controller, formats: [:html, :json]
4242+4343+ use Gettext, backend: CometWeb.Gettext
4444+4545+ import Plug.Conn
4646+4747+ unquote(verified_routes())
4848+ end
4949+ end
5050+5151+ def live_view do
5252+ quote do
5353+ use Phoenix.LiveView
5454+5555+ unquote(html_helpers())
5656+ end
5757+ end
5858+5959+ def live_component do
6060+ quote do
6161+ use Phoenix.LiveComponent
6262+6363+ unquote(html_helpers())
6464+ end
6565+ end
6666+6767+ def html do
6868+ quote do
6969+ use Phoenix.Component
7070+7171+ # Import convenience functions from controllers
7272+ import Phoenix.Controller,
7373+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
7474+7575+ # Include general helpers for rendering HTML
7676+ unquote(html_helpers())
7777+ end
7878+ end
7979+8080+ defp html_helpers do
8181+ quote do
8282+ # Translation
8383+ use Gettext, backend: CometWeb.Gettext
8484+8585+ # HTML escaping functionality
8686+ import Phoenix.HTML
8787+ # Core UI components
8888+ import CometWeb.CoreComponents
8989+9090+ # Common modules used in templates
9191+ alias Phoenix.LiveView.JS
9292+ alias CometWeb.Layouts
9393+9494+ # Routes generation with the ~p sigil
9595+ unquote(verified_routes())
9696+ end
9797+ end
9898+9999+ def verified_routes do
100100+ quote do
101101+ use Phoenix.VerifiedRoutes,
102102+ endpoint: CometWeb.Endpoint,
103103+ router: CometWeb.Router,
104104+ statics: CometWeb.static_paths()
105105+ end
106106+ end
107107+108108+ @doc """
109109+ When used, dispatch to the appropriate controller/live_view/etc.
110110+ """
111111+ defmacro __using__(which) when is_atom(which) do
112112+ apply(__MODULE__, which, [])
113113+ end
114114+end
+493
lib/comet_web/components/core_components.ex
···11+defmodule CometWeb.CoreComponents do
22+ @moduledoc """
33+ Provides core UI components.
44+55+ At first glance, this module may seem daunting, but its goal is to provide
66+ core building blocks for your application, such as tables, forms, and
77+ inputs. The components consist mostly of markup and are well-documented
88+ with doc strings and declarative assigns. You may customize and style
99+ them in any way you want, based on your application growth and needs.
1010+1111+ The foundation for styling is Tailwind CSS, a utility-first CSS framework. Here are useful references:
1212+1313+ * [Tailwind CSS](https://tailwindcss.com) - the foundational framework
1414+ we build on. You will use it for layout, sizing, flexbox, grid, and
1515+ spacing.
1616+1717+ * [Heroicons](https://heroicons.com) - see `icon/1` for usage.
1818+1919+ * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
2020+ the component system used by Phoenix. Some components, such as `<.link>`
2121+ and `<.form>`, are defined there.
2222+2323+ """
2424+ use Phoenix.Component
2525+ use Gettext, backend: CometWeb.Gettext
2626+2727+ alias Phoenix.LiveView.JS
2828+2929+ @doc """
3030+ Renders flash notices.
3131+3232+ ## Examples
3333+3434+ <.flash kind={:info} flash={@flash} />
3535+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
3636+ """
3737+ attr :id, :string, doc: "the optional id of flash container"
3838+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
3939+ attr :title, :string, default: nil
4040+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
4141+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
4242+4343+ slot :inner_block, doc: "the optional inner block that renders the flash message"
4444+4545+ def flash(assigns) do
4646+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
4747+4848+ ~H"""
4949+ <div
5050+ :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
5151+ id={@id}
5252+ phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
5353+ role="alert"
5454+ class="toast toast-top toast-end z-50"
5555+ {@rest}
5656+ >
5757+ <div class={[
5858+ "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
5959+ @kind == :info && "alert-info",
6060+ @kind == :error && "alert-error"
6161+ ]}>
6262+ <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
6363+ <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
6464+ <div>
6565+ <p :if={@title} class="font-semibold">{@title}</p>
6666+ <p>{msg}</p>
6767+ </div>
6868+ <div class="flex-1" />
6969+ <button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
7070+ <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
7171+ </button>
7272+ </div>
7373+ </div>
7474+ """
7575+ end
7676+7777+ @doc """
7878+ Renders a button with navigation support.
7979+8080+ ## Examples
8181+8282+ <.button>Send!</.button>
8383+ <.button phx-click="go" variant="primary">Send!</.button>
8484+ <.button navigate={~p"/"}>Home</.button>
8585+ """
8686+ attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
8787+ attr :class, :any
8888+ attr :variant, :string, values: ~w(primary)
8989+ slot :inner_block, required: true
9090+9191+ def button(%{rest: rest} = assigns) do
9292+ variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
9393+9494+ assigns =
9595+ assign_new(assigns, :class, fn ->
9696+ ["btn", Map.fetch!(variants, assigns[:variant])]
9797+ end)
9898+9999+ if rest[:href] || rest[:navigate] || rest[:patch] do
100100+ ~H"""
101101+ <.link class={@class} {@rest}>
102102+ {render_slot(@inner_block)}
103103+ </.link>
104104+ """
105105+ else
106106+ ~H"""
107107+ <button class={@class} {@rest}>
108108+ {render_slot(@inner_block)}
109109+ </button>
110110+ """
111111+ end
112112+ end
113113+114114+ @doc """
115115+ Renders an input with label and error messages.
116116+117117+ A `Phoenix.HTML.FormField` may be passed as argument,
118118+ which is used to retrieve the input name, id, and values.
119119+ Otherwise all attributes may be passed explicitly.
120120+121121+ ## Types
122122+123123+ This function accepts all HTML input types, considering that:
124124+125125+ * You may also set `type="select"` to render a `<select>` tag
126126+127127+ * `type="checkbox"` is used exclusively to render boolean values
128128+129129+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
130130+131131+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
132132+ for more information. Unsupported types, such as radio, are best
133133+ written directly in your templates.
134134+135135+ ## Examples
136136+137137+ ```heex
138138+ <.input field={@form[:email]} type="email" />
139139+ <.input name="my-input" errors={["oh no!"]} />
140140+ ```
141141+142142+ ## Select type
143143+144144+ When using `type="select"`, you must pass the `options` and optionally
145145+ a `value` to mark which option should be preselected.
146146+147147+ ```heex
148148+ <.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
149149+ ```
150150+151151+ For more information on what kind of data can be passed to `options` see
152152+ [`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
153153+ """
154154+ attr :id, :any, default: nil
155155+ attr :name, :any
156156+ attr :label, :string, default: nil
157157+ attr :value, :any
158158+159159+ attr :type, :string,
160160+ default: "text",
161161+ values: ~w(checkbox color date datetime-local email file month number password
162162+ search select tel text textarea time url week hidden)
163163+164164+ attr :field, Phoenix.HTML.FormField,
165165+ doc: "a form field struct retrieved from the form, for example: @form[:email]"
166166+167167+ attr :errors, :list, default: []
168168+ attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
169169+ attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
170170+ attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
171171+ attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
172172+ attr :class, :any, default: nil, doc: "the input class to use over defaults"
173173+ attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
174174+175175+ attr :rest, :global,
176176+ include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
177177+ multiple pattern placeholder readonly required rows size step)
178178+179179+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
180180+ errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
181181+182182+ assigns
183183+ |> assign(field: nil, id: assigns.id || field.id)
184184+ |> assign(:errors, Enum.map(errors, &translate_error(&1)))
185185+ |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
186186+ |> assign_new(:value, fn -> field.value end)
187187+ |> input()
188188+ end
189189+190190+ def input(%{type: "hidden"} = assigns) do
191191+ ~H"""
192192+ <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
193193+ """
194194+ end
195195+196196+ def input(%{type: "checkbox"} = assigns) do
197197+ assigns =
198198+ assign_new(assigns, :checked, fn ->
199199+ Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
200200+ end)
201201+202202+ ~H"""
203203+ <div class="fieldset mb-2">
204204+ <label>
205205+ <input
206206+ type="hidden"
207207+ name={@name}
208208+ value="false"
209209+ disabled={@rest[:disabled]}
210210+ form={@rest[:form]}
211211+ />
212212+ <span class="label">
213213+ <input
214214+ type="checkbox"
215215+ id={@id}
216216+ name={@name}
217217+ value="true"
218218+ checked={@checked}
219219+ class={@class || "checkbox checkbox-sm"}
220220+ {@rest}
221221+ />{@label}
222222+ </span>
223223+ </label>
224224+ <.error :for={msg <- @errors}>{msg}</.error>
225225+ </div>
226226+ """
227227+ end
228228+229229+ def input(%{type: "select"} = assigns) do
230230+ ~H"""
231231+ <div class="fieldset mb-2">
232232+ <label>
233233+ <span :if={@label} class="label mb-1">{@label}</span>
234234+ <select
235235+ id={@id}
236236+ name={@name}
237237+ class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
238238+ multiple={@multiple}
239239+ {@rest}
240240+ >
241241+ <option :if={@prompt} value="">{@prompt}</option>
242242+ {Phoenix.HTML.Form.options_for_select(@options, @value)}
243243+ </select>
244244+ </label>
245245+ <.error :for={msg <- @errors}>{msg}</.error>
246246+ </div>
247247+ """
248248+ end
249249+250250+ def input(%{type: "textarea"} = assigns) do
251251+ ~H"""
252252+ <div class="fieldset mb-2">
253253+ <label>
254254+ <span :if={@label} class="label mb-1">{@label}</span>
255255+ <textarea
256256+ id={@id}
257257+ name={@name}
258258+ class={[
259259+ @class || "w-full textarea",
260260+ @errors != [] && (@error_class || "textarea-error")
261261+ ]}
262262+ {@rest}
263263+ >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
264264+ </label>
265265+ <.error :for={msg <- @errors}>{msg}</.error>
266266+ </div>
267267+ """
268268+ end
269269+270270+ # All other inputs text, datetime-local, url, password, etc. are handled here...
271271+ def input(assigns) do
272272+ ~H"""
273273+ <div class="fieldset mb-2">
274274+ <label>
275275+ <span :if={@label} class="label mb-1">{@label}</span>
276276+ <input
277277+ type={@type}
278278+ name={@name}
279279+ id={@id}
280280+ value={Phoenix.HTML.Form.normalize_value(@type, @value)}
281281+ class={[
282282+ @class || "w-full input",
283283+ @errors != [] && (@error_class || "input-error")
284284+ ]}
285285+ {@rest}
286286+ />
287287+ </label>
288288+ <.error :for={msg <- @errors}>{msg}</.error>
289289+ </div>
290290+ """
291291+ end
292292+293293+ # Helper used by inputs to generate form errors
294294+ defp error(assigns) do
295295+ ~H"""
296296+ <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
297297+ <.icon name="hero-exclamation-circle" class="size-5" />
298298+ {render_slot(@inner_block)}
299299+ </p>
300300+ """
301301+ end
302302+303303+ @doc """
304304+ Renders a header with title.
305305+ """
306306+ slot :inner_block, required: true
307307+ slot :subtitle
308308+ slot :actions
309309+310310+ def header(assigns) do
311311+ ~H"""
312312+ <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
313313+ <div>
314314+ <h1 class="text-lg font-semibold leading-8">
315315+ {render_slot(@inner_block)}
316316+ </h1>
317317+ <p :if={@subtitle != []} class="text-sm text-base-content/70">
318318+ {render_slot(@subtitle)}
319319+ </p>
320320+ </div>
321321+ <div class="flex-none">{render_slot(@actions)}</div>
322322+ </header>
323323+ """
324324+ end
325325+326326+ @doc """
327327+ Renders a table with generic styling.
328328+329329+ ## Examples
330330+331331+ <.table id="users" rows={@users}>
332332+ <:col :let={user} label="id">{user.id}</:col>
333333+ <:col :let={user} label="username">{user.username}</:col>
334334+ </.table>
335335+ """
336336+ attr :id, :string, required: true
337337+ attr :rows, :list, required: true
338338+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
339339+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
340340+341341+ attr :row_item, :any,
342342+ default: &Function.identity/1,
343343+ doc: "the function for mapping each row before calling the :col and :action slots"
344344+345345+ slot :col, required: true do
346346+ attr :label, :string
347347+ end
348348+349349+ slot :action, doc: "the slot for showing user actions in the last table column"
350350+351351+ def table(assigns) do
352352+ assigns =
353353+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
354354+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
355355+ end
356356+357357+ ~H"""
358358+ <table class="table table-zebra">
359359+ <thead>
360360+ <tr>
361361+ <th :for={col <- @col}>{col[:label]}</th>
362362+ <th :if={@action != []}>
363363+ <span class="sr-only">{gettext("Actions")}</span>
364364+ </th>
365365+ </tr>
366366+ </thead>
367367+ <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
368368+ <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
369369+ <td
370370+ :for={col <- @col}
371371+ phx-click={@row_click && @row_click.(row)}
372372+ class={@row_click && "hover:cursor-pointer"}
373373+ >
374374+ {render_slot(col, @row_item.(row))}
375375+ </td>
376376+ <td :if={@action != []} class="w-0 font-semibold">
377377+ <div class="flex gap-4">
378378+ <%= for action <- @action do %>
379379+ {render_slot(action, @row_item.(row))}
380380+ <% end %>
381381+ </div>
382382+ </td>
383383+ </tr>
384384+ </tbody>
385385+ </table>
386386+ """
387387+ end
388388+389389+ @doc """
390390+ Renders a data list.
391391+392392+ ## Examples
393393+394394+ <.list>
395395+ <:item title="Title">{@post.title}</:item>
396396+ <:item title="Views">{@post.views}</:item>
397397+ </.list>
398398+ """
399399+ slot :item, required: true do
400400+ attr :title, :string, required: true
401401+ end
402402+403403+ def list(assigns) do
404404+ ~H"""
405405+ <ul class="list">
406406+ <li :for={item <- @item} class="list-row">
407407+ <div class="list-col-grow">
408408+ <div class="font-bold">{item.title}</div>
409409+ <div>{render_slot(item)}</div>
410410+ </div>
411411+ </li>
412412+ </ul>
413413+ """
414414+ end
415415+416416+ @doc """
417417+ Renders a [Heroicon](https://heroicons.com).
418418+419419+ Heroicons come in three styles – outline, solid, and mini.
420420+ By default, the outline style is used, but solid and mini may
421421+ be applied by using the `-solid` and `-mini` suffix.
422422+423423+ You can customize the size and colors of the icons by setting
424424+ width, height, and background color classes.
425425+426426+ Icons are extracted from the `deps/heroicons` directory and bundled within
427427+ your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
428428+429429+ ## Examples
430430+431431+ <.icon name="hero-x-mark" />
432432+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
433433+ """
434434+ attr :name, :string, required: true
435435+ attr :class, :any, default: "size-4"
436436+437437+ def icon(%{name: "hero-" <> _} = assigns) do
438438+ ~H"""
439439+ <span class={[@name, @class]} />
440440+ """
441441+ end
442442+443443+ ## JS Commands
444444+445445+ def show(js \\ %JS{}, selector) do
446446+ JS.show(js,
447447+ to: selector,
448448+ time: 300,
449449+ transition:
450450+ {"transition-all ease-out duration-300",
451451+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
452452+ "opacity-100 translate-y-0 sm:scale-100"}
453453+ )
454454+ end
455455+456456+ def hide(js \\ %JS{}, selector) do
457457+ JS.hide(js,
458458+ to: selector,
459459+ time: 200,
460460+ transition:
461461+ {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
462462+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
463463+ )
464464+ end
465465+466466+ @doc """
467467+ Translates an error message using gettext.
468468+ """
469469+ def translate_error({msg, opts}) do
470470+ # When using gettext, we typically pass the strings we want
471471+ # to translate as a static argument:
472472+ #
473473+ # # Translate the number of files with plural rules
474474+ # dngettext("errors", "1 file", "%{count} files", count)
475475+ #
476476+ # However the error messages in our forms and APIs are generated
477477+ # dynamically, so we need to translate them by calling Gettext
478478+ # with our gettext backend as first argument. Translations are
479479+ # available in the errors.po file (as we use the "errors" domain).
480480+ if count = opts[:count] do
481481+ Gettext.dngettext(CometWeb.Gettext, "errors", msg, msg, count, opts)
482482+ else
483483+ Gettext.dgettext(CometWeb.Gettext, "errors", msg, opts)
484484+ end
485485+ end
486486+487487+ @doc """
488488+ Translates the errors for a field from a keyword list of errors.
489489+ """
490490+ def translate_errors(errors, field) when is_list(errors) do
491491+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
492492+ end
493493+end
+154
lib/comet_web/components/layouts.ex
···11+defmodule CometWeb.Layouts do
22+ @moduledoc """
33+ This module holds layouts and related functionality
44+ used by your application.
55+ """
66+ use CometWeb, :html
77+88+ # Embed all files in layouts/* within this module.
99+ # The default root.html.heex file contains the HTML
1010+ # skeleton of your application, namely HTML headers
1111+ # and other static content.
1212+ embed_templates "layouts/*"
1313+1414+ @doc """
1515+ Renders your app layout.
1616+1717+ This function is typically invoked from every template,
1818+ and it often contains your application menu, sidebar,
1919+ or similar.
2020+2121+ ## Examples
2222+2323+ <Layouts.app flash={@flash}>
2424+ <h1>Content</h1>
2525+ </Layouts.app>
2626+2727+ """
2828+ attr :flash, :map, required: true, doc: "the map of flash messages"
2929+3030+ attr :current_scope, :map,
3131+ default: nil,
3232+ doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
3333+3434+ slot :inner_block, required: true
3535+3636+ def app(assigns) do
3737+ ~H"""
3838+ <header class="navbar px-4 sm:px-6 lg:px-8">
3939+ <div class="flex-1">
4040+ <a href="/" class="flex-1 flex w-fit items-center gap-2">
4141+ <img src={~p"/images/logo.svg"} width="36" />
4242+ <span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
4343+ </a>
4444+ </div>
4545+ <div class="flex-none">
4646+ <ul class="flex flex-column px-1 space-x-4 items-center">
4747+ <li>
4848+ <a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
4949+ </li>
5050+ <li>
5151+ <a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
5252+ </li>
5353+ <li>
5454+ <.theme_toggle />
5555+ </li>
5656+ <li>
5757+ <a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
5858+ Get Started <span aria-hidden="true">→</span>
5959+ </a>
6060+ </li>
6161+ </ul>
6262+ </div>
6363+ </header>
6464+6565+ <main class="px-4 py-20 sm:px-6 lg:px-8">
6666+ <div class="mx-auto max-w-2xl space-y-4">
6767+ {render_slot(@inner_block)}
6868+ </div>
6969+ </main>
7070+7171+ <.flash_group flash={@flash} />
7272+ """
7373+ end
7474+7575+ @doc """
7676+ Shows the flash group with standard titles and content.
7777+7878+ ## Examples
7979+8080+ <.flash_group flash={@flash} />
8181+ """
8282+ attr :flash, :map, required: true, doc: "the map of flash messages"
8383+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
8484+8585+ def flash_group(assigns) do
8686+ ~H"""
8787+ <div id={@id} aria-live="polite">
8888+ <.flash kind={:info} flash={@flash} />
8989+ <.flash kind={:error} flash={@flash} />
9090+9191+ <.flash
9292+ id="client-error"
9393+ kind={:error}
9494+ title={gettext("We can't find the internet")}
9595+ phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
9696+ phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
9797+ hidden
9898+ >
9999+ {gettext("Attempting to reconnect")}
100100+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
101101+ </.flash>
102102+103103+ <.flash
104104+ id="server-error"
105105+ kind={:error}
106106+ title={gettext("Something went wrong!")}
107107+ phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
108108+ phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
109109+ hidden
110110+ >
111111+ {gettext("Attempting to reconnect")}
112112+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
113113+ </.flash>
114114+ </div>
115115+ """
116116+ end
117117+118118+ @doc """
119119+ Provides dark vs light theme toggle based on themes defined in app.css.
120120+121121+ See <head> in root.html.heex which applies the theme before page load.
122122+ """
123123+ def theme_toggle(assigns) do
124124+ ~H"""
125125+ <div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
126126+ <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]" />
127127+128128+ <button
129129+ class="flex p-2 cursor-pointer w-1/3"
130130+ phx-click={JS.dispatch("phx:set-theme")}
131131+ data-phx-theme="system"
132132+ >
133133+ <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
134134+ </button>
135135+136136+ <button
137137+ class="flex p-2 cursor-pointer w-1/3"
138138+ phx-click={JS.dispatch("phx:set-theme")}
139139+ data-phx-theme="light"
140140+ >
141141+ <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
142142+ </button>
143143+144144+ <button
145145+ class="flex p-2 cursor-pointer w-1/3"
146146+ phx-click={JS.dispatch("phx:set-theme")}
147147+ data-phx-theme="dark"
148148+ >
149149+ <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
150150+ </button>
151151+ </div>
152152+ """
153153+ end
154154+end
···11+defmodule CometWeb.ErrorHTML do
22+ @moduledoc """
33+ This module is invoked by your endpoint in case of errors on HTML requests.
44+55+ See config/config.exs.
66+ """
77+ use CometWeb, :html
88+99+ # If you want to customize your error pages,
1010+ # uncomment the embed_templates/1 call below
1111+ # and add pages to the error directory:
1212+ #
1313+ # * lib/comet_web/controllers/error_html/404.html.heex
1414+ # * lib/comet_web/controllers/error_html/500.html.heex
1515+ #
1616+ # embed_templates "error_html/*"
1717+1818+ # The default is to render a plain text page based on
1919+ # the template name. For example, "404.html" becomes
2020+ # "Not Found".
2121+ def render(template, _assigns) do
2222+ Phoenix.Controller.status_message_from_template(template)
2323+ end
2424+end
+7
lib/comet_web/controllers/page_controller.ex
···11+defmodule CometWeb.PageController do
22+ use CometWeb, :controller
33+44+ def home(conn, _params) do
55+ render(conn, :home)
66+ end
77+end
+10
lib/comet_web/controllers/page_html.ex
···11+defmodule CometWeb.PageHTML do
22+ @moduledoc """
33+ This module contains pages rendered by PageController.
44+55+ See the `page_html` directory for all templates available.
66+ """
77+ use CometWeb, :html
88+99+ embed_templates "page_html/*"
1010+end
···11+defmodule CometWeb.Gettext do
22+ @moduledoc """
33+ A module providing Internationalization with a gettext-based API.
44+55+ By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
66+ that you can use in your application. To use this Gettext backend module,
77+ call `use Gettext` and pass it as an option:
88+99+ use Gettext, backend: CometWeb.Gettext
1010+1111+ # Simple translation
1212+ gettext("Here is the string to translate")
1313+1414+ # Plural translation
1515+ ngettext("Here is the string to translate",
1616+ "Here are the strings to translate",
1717+ 3)
1818+1919+ # Domain-based translation
2020+ dgettext("errors", "Here is the error message to translate")
2121+2222+ See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
2323+ """
2424+ use Gettext.Backend, otp_app: :comet
2525+end
+44
lib/comet_web/router.ex
···11+defmodule CometWeb.Router do
22+ use CometWeb, :router
33+44+ pipeline :browser do
55+ plug :accepts, ["html"]
66+ plug :fetch_session
77+ plug :fetch_live_flash
88+ plug :put_root_layout, html: {CometWeb.Layouts, :root}
99+ plug :protect_from_forgery
1010+ plug :put_secure_browser_headers
1111+ end
1212+1313+ pipeline :api do
1414+ plug :accepts, ["json"]
1515+ end
1616+1717+ scope "/", CometWeb do
1818+ pipe_through :browser
1919+2020+ get "/", PageController, :home
2121+ end
2222+2323+ # Other scopes may use custom stacks.
2424+ # scope "/api", CometWeb do
2525+ # pipe_through :api
2626+ # end
2727+2828+ # Enable LiveDashboard and Swoosh mailbox preview in development
2929+ if Application.compile_env(:comet, :dev_routes) do
3030+ # If you want to use the LiveDashboard in production, you should put
3131+ # it behind authentication and allow only admins to access it.
3232+ # If your application does not have an admins-only section yet,
3333+ # you can use Plug.BasicAuth to set up some basic authentication
3434+ # as long as you are also using SSL (which you should anyway).
3535+ import Phoenix.LiveDashboard.Router
3636+3737+ scope "/dev" do
3838+ pipe_through :browser
3939+4040+ live_dashboard "/dashboard", metrics: CometWeb.Telemetry
4141+ forward "/mailbox", Plug.Swoosh.MailboxPreview
4242+ end
4343+ end
4444+end
+94
mix.exs
···11+defmodule Comet.MixProject do
22+ use Mix.Project
33+44+ def project do
55+ [
66+ app: :comet,
77+ version: "0.1.0",
88+ elixir: "~> 1.15",
99+ elixirc_paths: elixirc_paths(Mix.env()),
1010+ start_permanent: Mix.env() == :prod,
1111+ aliases: aliases(),
1212+ deps: deps(),
1313+ compilers: [:phoenix_live_view] ++ Mix.compilers(),
1414+ listeners: [Phoenix.CodeReloader]
1515+ ]
1616+ end
1717+1818+ # Configuration for the OTP application.
1919+ #
2020+ # Type `mix help compile.app` for more information.
2121+ def application do
2222+ [
2323+ mod: {Comet.Application, []},
2424+ extra_applications: [:logger, :runtime_tools]
2525+ ]
2626+ end
2727+2828+ def cli do
2929+ [
3030+ preferred_envs: [precommit: :test]
3131+ ]
3232+ end
3333+3434+ # Specifies which paths to compile per environment.
3535+ defp elixirc_paths(:test), do: ["lib", "test/support"]
3636+ defp elixirc_paths(_), do: ["lib"]
3737+3838+ # Specifies your project dependencies.
3939+ #
4040+ # Type `mix help deps` for examples and options.
4141+ defp deps do
4242+ [
4343+ {:phoenix, "~> 1.8.2"},
4444+ {:phoenix_ecto, "~> 4.5"},
4545+ {:ecto_sql, "~> 3.13"},
4646+ {:postgrex, ">= 0.0.0"},
4747+ {:phoenix_html, "~> 4.1"},
4848+ {:phoenix_live_reload, "~> 1.2", only: :dev},
4949+ {:phoenix_live_view, "~> 1.1.0"},
5050+ {:lazy_html, ">= 0.1.0", only: :test},
5151+ {:phoenix_live_dashboard, "~> 0.8.3"},
5252+ {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
5353+ {:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
5454+ {:heroicons,
5555+ github: "tailwindlabs/heroicons",
5656+ tag: "v2.2.0",
5757+ sparse: "optimized",
5858+ app: false,
5959+ compile: false,
6060+ depth: 1},
6161+ {:swoosh, "~> 1.16"},
6262+ {:req, "~> 0.5"},
6363+ {:telemetry_metrics, "~> 1.0"},
6464+ {:telemetry_poller, "~> 1.0"},
6565+ {:gettext, "~> 1.0"},
6666+ {:jason, "~> 1.2"},
6767+ {:dns_cluster, "~> 0.2.0"},
6868+ {:bandit, "~> 1.5"}
6969+ ]
7070+ end
7171+7272+ # Aliases are shortcuts or tasks specific to the current project.
7373+ # For example, to install project dependencies and perform other setup tasks, run:
7474+ #
7575+ # $ mix setup
7676+ #
7777+ # See the documentation for `Mix` for more info on aliases.
7878+ defp aliases do
7979+ [
8080+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
8181+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
8282+ "ecto.reset": ["ecto.drop", "ecto.setup"],
8383+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
8484+ "assets.setup": ["cmd pnpm install", "tailwind.install --if-missing", "esbuild.install --if-missing"],
8585+ "assets.build": ["compile", "tailwind comet", "esbuild comet"],
8686+ "assets.deploy": [
8787+ "tailwind comet --minify",
8888+ "esbuild comet --minify",
8989+ "phx.digest"
9090+ ],
9191+ precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
9292+ ]
9393+ end
9494+end
···11+## `msgid`s in this file come from POT (.pot) files.
22+##
33+## Do not add, change, or remove `msgid`s manually here as
44+## they're tied to the ones in the corresponding POT file
55+## (with the same domain).
66+##
77+## Use `mix gettext.extract --merge` or `mix gettext.merge`
88+## to merge POT files into PO files.
99+msgid ""
1010+msgstr ""
1111+"Language: en\n"
1212+1313+## From Ecto.Changeset.cast/4
1414+msgid "can't be blank"
1515+msgstr ""
1616+1717+## From Ecto.Changeset.unique_constraint/3
1818+msgid "has already been taken"
1919+msgstr ""
2020+2121+## From Ecto.Changeset.put_change/3
2222+msgid "is invalid"
2323+msgstr ""
2424+2525+## From Ecto.Changeset.validate_acceptance/3
2626+msgid "must be accepted"
2727+msgstr ""
2828+2929+## From Ecto.Changeset.validate_format/3
3030+msgid "has invalid format"
3131+msgstr ""
3232+3333+## From Ecto.Changeset.validate_subset/3
3434+msgid "has an invalid entry"
3535+msgstr ""
3636+3737+## From Ecto.Changeset.validate_exclusion/3
3838+msgid "is reserved"
3939+msgstr ""
4040+4141+## From Ecto.Changeset.validate_confirmation/3
4242+msgid "does not match confirmation"
4343+msgstr ""
4444+4545+## From Ecto.Changeset.no_assoc_constraint/3
4646+msgid "is still associated with this entry"
4747+msgstr ""
4848+4949+msgid "are still associated with this entry"
5050+msgstr ""
5151+5252+## From Ecto.Changeset.validate_length/3
5353+msgid "should have %{count} item(s)"
5454+msgid_plural "should have %{count} item(s)"
5555+msgstr[0] ""
5656+msgstr[1] ""
5757+5858+msgid "should be %{count} character(s)"
5959+msgid_plural "should be %{count} character(s)"
6060+msgstr[0] ""
6161+msgstr[1] ""
6262+6363+msgid "should be %{count} byte(s)"
6464+msgid_plural "should be %{count} byte(s)"
6565+msgstr[0] ""
6666+msgstr[1] ""
6767+6868+msgid "should have at least %{count} item(s)"
6969+msgid_plural "should have at least %{count} item(s)"
7070+msgstr[0] ""
7171+msgstr[1] ""
7272+7373+msgid "should be at least %{count} character(s)"
7474+msgid_plural "should be at least %{count} character(s)"
7575+msgstr[0] ""
7676+msgstr[1] ""
7777+7878+msgid "should be at least %{count} byte(s)"
7979+msgid_plural "should be at least %{count} byte(s)"
8080+msgstr[0] ""
8181+msgstr[1] ""
8282+8383+msgid "should have at most %{count} item(s)"
8484+msgid_plural "should have at most %{count} item(s)"
8585+msgstr[0] ""
8686+msgstr[1] ""
8787+8888+msgid "should be at most %{count} character(s)"
8989+msgid_plural "should be at most %{count} character(s)"
9090+msgstr[0] ""
9191+msgstr[1] ""
9292+9393+msgid "should be at most %{count} byte(s)"
9494+msgid_plural "should be at most %{count} byte(s)"
9595+msgstr[0] ""
9696+msgstr[1] ""
9797+9898+## From Ecto.Changeset.validate_number/3
9999+msgid "must be less than %{number}"
100100+msgstr ""
101101+102102+msgid "must be greater than %{number}"
103103+msgstr ""
104104+105105+msgid "must be less than or equal to %{number}"
106106+msgstr ""
107107+108108+msgid "must be greater than or equal to %{number}"
109109+msgstr ""
110110+111111+msgid "must be equal to %{number}"
112112+msgstr ""
+109
priv/gettext/errors.pot
···11+## This is a PO Template file.
22+##
33+## `msgid`s here are often extracted from source code.
44+## Add new translations manually only if they're dynamic
55+## translations that can't be statically extracted.
66+##
77+## Run `mix gettext.extract` to bring this file up to
88+## date. Leave `msgstr`s empty as changing them here has no
99+## effect: edit them in PO (`.po`) files instead.
1010+## From Ecto.Changeset.cast/4
1111+msgid "can't be blank"
1212+msgstr ""
1313+1414+## From Ecto.Changeset.unique_constraint/3
1515+msgid "has already been taken"
1616+msgstr ""
1717+1818+## From Ecto.Changeset.put_change/3
1919+msgid "is invalid"
2020+msgstr ""
2121+2222+## From Ecto.Changeset.validate_acceptance/3
2323+msgid "must be accepted"
2424+msgstr ""
2525+2626+## From Ecto.Changeset.validate_format/3
2727+msgid "has invalid format"
2828+msgstr ""
2929+3030+## From Ecto.Changeset.validate_subset/3
3131+msgid "has an invalid entry"
3232+msgstr ""
3333+3434+## From Ecto.Changeset.validate_exclusion/3
3535+msgid "is reserved"
3636+msgstr ""
3737+3838+## From Ecto.Changeset.validate_confirmation/3
3939+msgid "does not match confirmation"
4040+msgstr ""
4141+4242+## From Ecto.Changeset.no_assoc_constraint/3
4343+msgid "is still associated with this entry"
4444+msgstr ""
4545+4646+msgid "are still associated with this entry"
4747+msgstr ""
4848+4949+## From Ecto.Changeset.validate_length/3
5050+msgid "should have %{count} item(s)"
5151+msgid_plural "should have %{count} item(s)"
5252+msgstr[0] ""
5353+msgstr[1] ""
5454+5555+msgid "should be %{count} character(s)"
5656+msgid_plural "should be %{count} character(s)"
5757+msgstr[0] ""
5858+msgstr[1] ""
5959+6060+msgid "should be %{count} byte(s)"
6161+msgid_plural "should be %{count} byte(s)"
6262+msgstr[0] ""
6363+msgstr[1] ""
6464+6565+msgid "should have at least %{count} item(s)"
6666+msgid_plural "should have at least %{count} item(s)"
6767+msgstr[0] ""
6868+msgstr[1] ""
6969+7070+msgid "should be at least %{count} character(s)"
7171+msgid_plural "should be at least %{count} character(s)"
7272+msgstr[0] ""
7373+msgstr[1] ""
7474+7575+msgid "should be at least %{count} byte(s)"
7676+msgid_plural "should be at least %{count} byte(s)"
7777+msgstr[0] ""
7878+msgstr[1] ""
7979+8080+msgid "should have at most %{count} item(s)"
8181+msgid_plural "should have at most %{count} item(s)"
8282+msgstr[0] ""
8383+msgstr[1] ""
8484+8585+msgid "should be at most %{count} character(s)"
8686+msgid_plural "should be at most %{count} character(s)"
8787+msgstr[0] ""
8888+msgstr[1] ""
8989+9090+msgid "should be at most %{count} byte(s)"
9191+msgid_plural "should be at most %{count} byte(s)"
9292+msgstr[0] ""
9393+msgstr[1] ""
9494+9595+## From Ecto.Changeset.validate_number/3
9696+msgid "must be less than %{number}"
9797+msgstr ""
9898+9999+msgid "must be greater than %{number}"
100100+msgstr ""
101101+102102+msgid "must be less than or equal to %{number}"
103103+msgstr ""
104104+105105+msgid "must be greater than or equal to %{number}"
106106+msgstr ""
107107+108108+msgid "must be equal to %{number}"
109109+msgstr ""
···11+defmodule CometWeb.PageControllerTest do
22+ use CometWeb.ConnCase
33+44+ test "GET /", %{conn: conn} do
55+ conn = get(conn, ~p"/")
66+ assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
77+ end
88+end