Initial commit: RateMyClient™ Phoenix application
Features: - User registration and authentication with email/password - Admin login with username-based authentication (separate from regular users) - Review system for contractors to rate clients - Star rating system with review forms - Client identification with private data protection - Contractor registration with document verification - Admin dashboard for review management - Contact form (demo, non-functional) - Responsive navigation with DaisyUI components - Docker Compose setup for production deployment - PostgreSQL database with Ecto migrations - High Vis color scheme (dark background with safety orange/green) Admin credentials: username: admin, password: admin123 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
9ece312442
89 changed files with 7085 additions and 0 deletions
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(iex -S mix run -e 'MyFirstElixirVibeCode.Accounts.create_user(%{username: \"\"admin\"\", password: \"\"admin123\"\", is_admin: true}) |> IO.inspect()')"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
41
.dockerignore
Normal file
41
.dockerignore
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
_build
|
||||||
|
deps
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
assets/node_modules
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Phoenix artifacts
|
||||||
|
priv/static/assets
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
6
.formatter.exs
Normal file
6
.formatter.exs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[
|
||||||
|
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||||
|
subdirectories: ["priv/*/migrations"],
|
||||||
|
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||||
|
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||||
|
]
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
|
/cover/
|
||||||
|
|
||||||
|
# The directory Mix downloads your dependencies sources to.
|
||||||
|
/deps/
|
||||||
|
|
||||||
|
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||||
|
/.fetch
|
||||||
|
|
||||||
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Temporary files, for example, from tests.
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
|
my_first_elixir_vibe_code-*.tar
|
||||||
|
|
||||||
|
# Ignore assets that are produced by build tools.
|
||||||
|
/priv/static/assets/
|
||||||
|
|
||||||
|
# Ignore digested assets cache.
|
||||||
|
/priv/static/cache_manifest.json
|
||||||
|
|
||||||
|
# In case you use Node.js/npm, you want to ignore these.
|
||||||
|
npm-debug.log
|
||||||
|
/assets/node_modules/
|
||||||
|
|
||||||
2
.tool-versions
Normal file
2
.tool-versions
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
erlang 28.1
|
||||||
|
elixir 1.19.0-rc.0-otp-28
|
||||||
334
AGENTS.md
Normal file
334
AGENTS.md
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
This is a web application written using the Phoenix web framework.
|
||||||
|
|
||||||
|
## Project guidelines
|
||||||
|
|
||||||
|
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
|
||||||
|
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
|
||||||
|
|
||||||
|
### Phoenix v1.8 guidelines
|
||||||
|
|
||||||
|
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
|
||||||
|
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
|
||||||
|
- Anytime you run into errors with no `current_scope` assign:
|
||||||
|
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
|
||||||
|
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
|
||||||
|
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
|
||||||
|
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
|
||||||
|
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors
|
||||||
|
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
|
||||||
|
custom classes must fully style the input
|
||||||
|
|
||||||
|
### JS and CSS guidelines
|
||||||
|
|
||||||
|
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
|
||||||
|
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
|
||||||
|
|
||||||
|
@import "tailwindcss" source(none);
|
||||||
|
@source "../css";
|
||||||
|
@source "../js";
|
||||||
|
@source "../../lib/my_app_web";
|
||||||
|
|
||||||
|
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
|
||||||
|
- **Never** use `@apply` when writing raw css
|
||||||
|
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
|
||||||
|
- Out of the box **only the app.js and app.css bundles are supported**
|
||||||
|
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
|
||||||
|
- You must import the vendor deps into app.js and app.css to use them
|
||||||
|
- **Never write inline <script>custom js</script> tags within templates**
|
||||||
|
|
||||||
|
### UI/UX & design guidelines
|
||||||
|
|
||||||
|
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
|
||||||
|
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
|
||||||
|
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
|
||||||
|
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
|
||||||
|
|
||||||
|
|
||||||
|
<!-- usage-rules-start -->
|
||||||
|
|
||||||
|
<!-- phoenix:elixir-start -->
|
||||||
|
## Elixir guidelines
|
||||||
|
|
||||||
|
- Elixir lists **do not support index based access via the access syntax**
|
||||||
|
|
||||||
|
**Never do this (invalid)**:
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
mylist = ["blue", "green"]
|
||||||
|
mylist[i]
|
||||||
|
|
||||||
|
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
mylist = ["blue", "green"]
|
||||||
|
Enum.at(mylist, i)
|
||||||
|
|
||||||
|
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
|
||||||
|
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
|
||||||
|
|
||||||
|
# INVALID: we are rebinding inside the `if` and the result never gets assigned
|
||||||
|
if connected?(socket) do
|
||||||
|
socket = assign(socket, :val, val)
|
||||||
|
end
|
||||||
|
|
||||||
|
# VALID: we rebind the result of the `if` to a new variable
|
||||||
|
socket =
|
||||||
|
if connected?(socket) do
|
||||||
|
assign(socket, :val, val)
|
||||||
|
end
|
||||||
|
|
||||||
|
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
|
||||||
|
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
|
||||||
|
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
|
||||||
|
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||||
|
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
|
||||||
|
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
|
||||||
|
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
|
||||||
|
|
||||||
|
## Mix guidelines
|
||||||
|
|
||||||
|
- Read the docs and options before using tasks (by using `mix help task_name`)
|
||||||
|
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
|
||||||
|
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
|
||||||
|
<!-- phoenix:elixir-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:phoenix-start -->
|
||||||
|
## Phoenix guidelines
|
||||||
|
|
||||||
|
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||||
|
|
||||||
|
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||||
|
|
||||||
|
scope "/admin", AppWeb.Admin do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
live "/users", UserLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||||
|
|
||||||
|
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||||
|
<!-- phoenix:phoenix-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:ecto-start -->
|
||||||
|
## Ecto Guidelines
|
||||||
|
|
||||||
|
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
|
||||||
|
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
|
||||||
|
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
|
||||||
|
- `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
|
||||||
|
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
|
||||||
|
- 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
|
||||||
|
<!-- phoenix:ecto-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:html-start -->
|
||||||
|
## Phoenix HTML guidelines
|
||||||
|
|
||||||
|
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
|
||||||
|
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
|
||||||
|
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
|
||||||
|
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
|
||||||
|
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
|
||||||
|
|
||||||
|
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
|
||||||
|
|
||||||
|
**Never do this (invalid)**:
|
||||||
|
|
||||||
|
<%= if condition do %>
|
||||||
|
...
|
||||||
|
<% else if other_condition %>
|
||||||
|
...
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
Instead **always** do this:
|
||||||
|
|
||||||
|
<%= cond do %>
|
||||||
|
<% condition -> %>
|
||||||
|
...
|
||||||
|
<% condition2 -> %>
|
||||||
|
...
|
||||||
|
<% true -> %>
|
||||||
|
...
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
|
||||||
|
|
||||||
|
<code phx-no-curly-interpolation>
|
||||||
|
let obj = {key: "val"}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
|
||||||
|
|
||||||
|
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
|
||||||
|
|
||||||
|
<a class={[
|
||||||
|
"px-2 text-white",
|
||||||
|
@some_flag && "py-5",
|
||||||
|
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
|
||||||
|
...
|
||||||
|
]}>Text</a>
|
||||||
|
|
||||||
|
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
|
||||||
|
|
||||||
|
and **never** do this, since it's invalid (note the missing `[` and `]`):
|
||||||
|
|
||||||
|
<a class={
|
||||||
|
"px-2 text-white",
|
||||||
|
@some_flag && "py-5"
|
||||||
|
}> ...
|
||||||
|
=> Raises compile syntax error on invalid HEEx attr syntax
|
||||||
|
|
||||||
|
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
|
||||||
|
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
|
||||||
|
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
|
||||||
|
|
||||||
|
**Always** do this:
|
||||||
|
|
||||||
|
<div id={@id}>
|
||||||
|
{@my_assign}
|
||||||
|
<%= if @some_block_condition do %>
|
||||||
|
{@another_assign}
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
and **Never** do this – the program will terminate with a syntax error:
|
||||||
|
|
||||||
|
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
|
||||||
|
<div id="<%= @invalid_interpolation %>">
|
||||||
|
{if @invalid_block_construct do}
|
||||||
|
{end}
|
||||||
|
</div>
|
||||||
|
<!-- phoenix:html-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:liveview-start -->
|
||||||
|
## Phoenix LiveView guidelines
|
||||||
|
|
||||||
|
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
|
||||||
|
- **Avoid LiveComponent's** unless you have a strong, specific need for them
|
||||||
|
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
|
||||||
|
- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
|
||||||
|
- **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
|
||||||
|
|
||||||
|
### LiveView streams
|
||||||
|
|
||||||
|
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
|
||||||
|
- basic append of N items - `stream(socket, :messages, [new_msg])`
|
||||||
|
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
|
||||||
|
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
|
||||||
|
- deleting items - `stream_delete(socket, :messages, msg)`
|
||||||
|
|
||||||
|
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
|
||||||
|
|
||||||
|
<div id="messages" phx-update="stream">
|
||||||
|
<div :for={{id, msg} <- @streams.messages} id={id}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
|
||||||
|
|
||||||
|
def handle_event("filter", %{"filter" => filter}, socket) do
|
||||||
|
# re-fetch the messages based on the filter
|
||||||
|
messages = list_messages(filter)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:messages_empty?, messages == [])
|
||||||
|
# reset the stream with the new messages
|
||||||
|
|> stream(:messages, messages, reset: true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
|
||||||
|
|
||||||
|
<div id="tasks" phx-update="stream">
|
||||||
|
<div class="hidden only:block">No tasks yet</div>
|
||||||
|
<div :for={{id, task} <- @stream.tasks} id={id}>
|
||||||
|
{task.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
|
||||||
|
|
||||||
|
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
|
||||||
|
|
||||||
|
### LiveView tests
|
||||||
|
|
||||||
|
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
|
||||||
|
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
|
||||||
|
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
|
||||||
|
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
|
||||||
|
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
|
||||||
|
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
|
||||||
|
- Focus on testing outcomes rather than implementation details
|
||||||
|
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
|
||||||
|
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
document = LazyHTML.from_fragment(html)
|
||||||
|
matches = LazyHTML.filter(document, "your-complex-selector")
|
||||||
|
IO.inspect(matches, label: "Matches")
|
||||||
|
|
||||||
|
### Form handling
|
||||||
|
|
||||||
|
#### Creating a form from params
|
||||||
|
|
||||||
|
If you want to create a form based on `handle_event` params:
|
||||||
|
|
||||||
|
def handle_event("submitted", params, socket) do
|
||||||
|
{:noreply, assign(socket, form: to_form(params))}
|
||||||
|
end
|
||||||
|
|
||||||
|
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
|
||||||
|
|
||||||
|
You can also specify a name to nest the params:
|
||||||
|
|
||||||
|
def handle_event("submitted", %{"user" => user_params}, socket) do
|
||||||
|
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
|
||||||
|
end
|
||||||
|
|
||||||
|
#### Creating a form from changesets
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
defmodule MyApp.Users.User do
|
||||||
|
use Ecto.Schema
|
||||||
|
...
|
||||||
|
end
|
||||||
|
|
||||||
|
And then you create a changeset that you pass to `to_form`:
|
||||||
|
|
||||||
|
%MyApp.Users.User{}
|
||||||
|
|> Ecto.Changeset.change()
|
||||||
|
|> to_form()
|
||||||
|
|
||||||
|
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
|
||||||
|
|
||||||
|
In the template, the form form assign can be passed to the `<.form>` function component:
|
||||||
|
|
||||||
|
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
|
||||||
|
<.input field={@form[:field]} type="text" />
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
|
||||||
|
|
||||||
|
#### Avoiding form errors
|
||||||
|
|
||||||
|
**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**:
|
||||||
|
|
||||||
|
<%!-- ALWAYS do this (valid) --%>
|
||||||
|
<.form for={@form} id="my-form">
|
||||||
|
<.input field={@form[:field]} type="text" />
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
And **never** do this:
|
||||||
|
|
||||||
|
<%!-- NEVER do this (invalid) --%>
|
||||||
|
<.form for={@changeset} id="my-form">
|
||||||
|
<.input field={@changeset[:field]} type="text" />
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
|
||||||
|
- **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
|
||||||
|
<!-- phoenix:liveview-end -->
|
||||||
|
|
||||||
|
<!-- usage-rules-end -->
|
||||||
123
DOCKER_README.md
Normal file
123
DOCKER_README.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# RateMyClient - Docker Setup
|
||||||
|
|
||||||
|
This guide will help you run the RateMyClient Phoenix application using Docker Compose.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Desktop installed and running
|
||||||
|
- Docker Compose (included with Docker Desktop)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Build and start the containers:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Build the Phoenix application Docker image
|
||||||
|
- Start PostgreSQL database
|
||||||
|
- Create the database
|
||||||
|
- Run migrations
|
||||||
|
- Create an admin user
|
||||||
|
- Start the Phoenix server
|
||||||
|
|
||||||
|
2. **Access the application:**
|
||||||
|
|
||||||
|
Open your browser and navigate to: http://localhost:4000
|
||||||
|
|
||||||
|
## Default Admin Credentials
|
||||||
|
|
||||||
|
- **Username:** `admin`
|
||||||
|
- **Password:** `admin123`
|
||||||
|
- **Admin Login:** http://localhost:4000/admin/login
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### Start containers (detached mode)
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop containers
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild after code changes
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access the Phoenix console
|
||||||
|
```bash
|
||||||
|
docker-compose exec web bin/my_first_elixir_vibe_code remote
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run migrations manually
|
||||||
|
```bash
|
||||||
|
docker-compose exec web bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.migrate()"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access PostgreSQL
|
||||||
|
```bash
|
||||||
|
docker-compose exec db psql -U postgres -d my_first_elixir_vibe_code_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
The application uses a persistent volume for PostgreSQL data:
|
||||||
|
- `postgres_data`: Stores database data
|
||||||
|
|
||||||
|
To completely reset the database:
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
You can customize the application by modifying the environment variables in `docker-compose.yml`:
|
||||||
|
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `SECRET_KEY_BASE`: Secret key for Phoenix (generate with `mix phx.gen.secret`)
|
||||||
|
- `PHX_HOST`: Hostname for the application
|
||||||
|
- `PORT`: Port to run the application on
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
If port 4000 is already in use, change the port mapping in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8080:4000" # Access via http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database connection issues
|
||||||
|
Make sure the database container is healthy:
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean rebuild
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
docker system prune -a
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Notes
|
||||||
|
|
||||||
|
This Docker setup is configured for production use. For actual production deployment:
|
||||||
|
|
||||||
|
1. Change the `SECRET_KEY_BASE` to a new secure value
|
||||||
|
2. Update database credentials in `docker-compose.yml`
|
||||||
|
3. Consider using environment files (.env) instead of hardcoded values
|
||||||
|
4. Set up SSL/TLS termination (nginx, traefik, etc.)
|
||||||
|
5. Configure proper backup strategies for the database
|
||||||
64
Dockerfile
Normal file
64
Dockerfile
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Build stage
|
||||||
|
FROM elixir:1.19.0-rc.0-otp-28-alpine AS build
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache build-base git nodejs npm
|
||||||
|
|
||||||
|
# Prepare build dir
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install hex + rebar
|
||||||
|
RUN mix local.hex --force && \
|
||||||
|
mix local.rebar --force
|
||||||
|
|
||||||
|
# Set build ENV
|
||||||
|
ENV MIX_ENV=prod
|
||||||
|
|
||||||
|
# Install mix dependencies
|
||||||
|
COPY mix.exs mix.lock ./
|
||||||
|
RUN mix deps.get --only prod
|
||||||
|
RUN mix deps.compile
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY config config
|
||||||
|
COPY priv priv
|
||||||
|
COPY lib lib
|
||||||
|
COPY assets assets
|
||||||
|
|
||||||
|
# Compile assets
|
||||||
|
RUN npm ci --prefix ./assets
|
||||||
|
RUN npm run deploy --prefix ./assets
|
||||||
|
RUN mix assets.deploy
|
||||||
|
|
||||||
|
# Compile the project
|
||||||
|
RUN mix compile
|
||||||
|
|
||||||
|
# Build release
|
||||||
|
RUN mix release
|
||||||
|
|
||||||
|
# App stage
|
||||||
|
FROM alpine:3.19 AS app
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache libstdc++ openssl ncurses-libs
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the release from build stage
|
||||||
|
COPY --from=build /app/_build/prod/rel/my_first_elixir_vibe_code ./
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN addgroup -g 1000 phoenix && \
|
||||||
|
adduser -D -u 1000 -G phoenix phoenix && \
|
||||||
|
chown -R phoenix:phoenix /app
|
||||||
|
|
||||||
|
USER phoenix
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["bin/my_first_elixir_vibe_code", "start"]
|
||||||
18
README.md
Normal file
18
README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# MyFirstElixirVibeCode
|
||||||
|
|
||||||
|
To start your Phoenix server:
|
||||||
|
|
||||||
|
* Run `mix setup` to install and setup dependencies
|
||||||
|
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||||
|
|
||||||
|
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||||
|
|
||||||
|
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
* Official website: https://www.phoenixframework.org/
|
||||||
|
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||||
|
* Docs: https://hexdocs.pm/phoenix
|
||||||
|
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||||
|
* Source: https://github.com/phoenixframework/phoenix
|
||||||
105
assets/css/app.css
Normal file
105
assets/css/app.css
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/* See the Tailwind configuration guide for advanced usage
|
||||||
|
https://tailwindcss.com/docs/configuration */
|
||||||
|
|
||||||
|
@import "tailwindcss" source(none);
|
||||||
|
@source "../css";
|
||||||
|
@source "../js";
|
||||||
|
@source "../../lib/my_first_elixir_vibe_code_web";
|
||||||
|
|
||||||
|
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
|
||||||
|
The heroicons installation itself is managed by your mix.exs */
|
||||||
|
@plugin "../vendor/heroicons";
|
||||||
|
|
||||||
|
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
|
||||||
|
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
|
||||||
|
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
|
||||||
|
@plugin "../vendor/daisyui" {
|
||||||
|
themes: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
|
||||||
|
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||||
|
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
|
||||||
|
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
||||||
|
@plugin "../vendor/daisyui-theme" {
|
||||||
|
name: "dark";
|
||||||
|
default: true;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: "dark";
|
||||||
|
--color-base-100: oklch(15% 0.01 252);
|
||||||
|
--color-base-200: oklch(12% 0.01 252);
|
||||||
|
--color-base-300: oklch(8% 0.01 252);
|
||||||
|
--color-base-content: oklch(98% 0 0);
|
||||||
|
--color-primary: oklch(75% 0.25 35);
|
||||||
|
--color-primary-content: oklch(10% 0 0);
|
||||||
|
--color-secondary: oklch(72% 0.28 130);
|
||||||
|
--color-secondary-content: oklch(10% 0 0);
|
||||||
|
--color-accent: oklch(78% 0.30 130);
|
||||||
|
--color-accent-content: oklch(10% 0 0);
|
||||||
|
--color-neutral: oklch(25% 0.02 252);
|
||||||
|
--color-neutral-content: oklch(98% 0 0);
|
||||||
|
--color-info: oklch(70% 0.20 230);
|
||||||
|
--color-info-content: oklch(10% 0 0);
|
||||||
|
--color-success: oklch(75% 0.28 140);
|
||||||
|
--color-success-content: oklch(10% 0 0);
|
||||||
|
--color-warning: oklch(80% 0.30 80);
|
||||||
|
--color-warning-content: oklch(10% 0 0);
|
||||||
|
--color-error: oklch(65% 0.30 25);
|
||||||
|
--color-error-content: oklch(98% 0 0);
|
||||||
|
--radius-selector: 0.25rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
--size-selector: 0.21875rem;
|
||||||
|
--size-field: 0.21875rem;
|
||||||
|
--border: 2px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "../vendor/daisyui-theme" {
|
||||||
|
name: "light";
|
||||||
|
default: false;
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: "light";
|
||||||
|
--color-base-100: oklch(98% 0 0);
|
||||||
|
--color-base-200: oklch(96% 0.001 286.375);
|
||||||
|
--color-base-300: oklch(92% 0.004 286.32);
|
||||||
|
--color-base-content: oklch(21% 0.006 285.885);
|
||||||
|
--color-primary: oklch(70% 0.213 47.604);
|
||||||
|
--color-primary-content: oklch(98% 0.016 73.684);
|
||||||
|
--color-secondary: oklch(55% 0.027 264.364);
|
||||||
|
--color-secondary-content: oklch(98% 0.002 247.839);
|
||||||
|
--color-accent: oklch(0% 0 0);
|
||||||
|
--color-accent-content: oklch(100% 0 0);
|
||||||
|
--color-neutral: oklch(44% 0.017 285.786);
|
||||||
|
--color-neutral-content: oklch(98% 0 0);
|
||||||
|
--color-info: oklch(62% 0.214 259.815);
|
||||||
|
--color-info-content: oklch(97% 0.014 254.604);
|
||||||
|
--color-success: oklch(70% 0.14 182.503);
|
||||||
|
--color-success-content: oklch(98% 0.014 180.72);
|
||||||
|
--color-warning: oklch(66% 0.179 58.318);
|
||||||
|
--color-warning-content: oklch(98% 0.022 95.277);
|
||||||
|
--color-error: oklch(58% 0.253 17.585);
|
||||||
|
--color-error-content: oklch(96% 0.015 12.422);
|
||||||
|
--radius-selector: 0.25rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
--size-selector: 0.21875rem;
|
||||||
|
--size-field: 0.21875rem;
|
||||||
|
--border: 1.5px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add variants based on LiveView classes */
|
||||||
|
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||||
|
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||||
|
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||||
|
|
||||||
|
/* Use the data attribute for dark mode */
|
||||||
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
|
/* Make LiveView wrapper divs transparent for layout */
|
||||||
|
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
||||||
|
|
||||||
|
/* This file is for your main application CSS */
|
||||||
83
assets/js/app.js
Normal file
83
assets/js/app.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||||
|
// to get started and then uncomment the line below.
|
||||||
|
// import "./user_socket.js"
|
||||||
|
|
||||||
|
// You can include dependencies in two ways.
|
||||||
|
//
|
||||||
|
// The simplest option is to put them in assets/vendor and
|
||||||
|
// import them using relative paths:
|
||||||
|
//
|
||||||
|
// import "../vendor/some-package.js"
|
||||||
|
//
|
||||||
|
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||||
|
// them using a path starting with the package name:
|
||||||
|
//
|
||||||
|
// import "some-package"
|
||||||
|
//
|
||||||
|
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
|
||||||
|
// To load it, simply add a second `<link>` to your `root.html.heex` file.
|
||||||
|
|
||||||
|
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||||
|
import "phoenix_html"
|
||||||
|
// Establish Phoenix Socket and LiveView configuration.
|
||||||
|
import {Socket} from "phoenix"
|
||||||
|
import {LiveSocket} from "phoenix_live_view"
|
||||||
|
import {hooks as colocatedHooks} from "phoenix-colocated/my_first_elixir_vibe_code"
|
||||||
|
import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
|
longPollFallbackMs: 2500,
|
||||||
|
params: {_csrf_token: csrfToken},
|
||||||
|
hooks: {...colocatedHooks},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show progress bar on live navigation and form submits
|
||||||
|
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||||
|
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||||
|
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||||
|
|
||||||
|
// connect if there are any LiveViews on the page
|
||||||
|
liveSocket.connect()
|
||||||
|
|
||||||
|
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||||
|
// >> liveSocket.enableDebug()
|
||||||
|
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||||
|
// >> liveSocket.disableLatencySim()
|
||||||
|
window.liveSocket = liveSocket
|
||||||
|
|
||||||
|
// The lines below enable quality of life phoenix_live_reload
|
||||||
|
// development features:
|
||||||
|
//
|
||||||
|
// 1. stream server logs to the browser console
|
||||||
|
// 2. click on elements to jump to their definitions in your code editor
|
||||||
|
//
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
|
||||||
|
// Enable server log streaming to client.
|
||||||
|
// Disable with reloader.disableServerLogs()
|
||||||
|
reloader.enableServerLogs()
|
||||||
|
|
||||||
|
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
||||||
|
//
|
||||||
|
// * click with "c" key pressed to open at caller location
|
||||||
|
// * click with "d" key pressed to open at function component definition location
|
||||||
|
let keyDown
|
||||||
|
window.addEventListener("keydown", e => keyDown = e.key)
|
||||||
|
window.addEventListener("keyup", e => keyDown = null)
|
||||||
|
window.addEventListener("click", e => {
|
||||||
|
if(keyDown === "c"){
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
reloader.openEditorAtCaller(e.target)
|
||||||
|
} else if(keyDown === "d"){
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
reloader.openEditorAtDef(e.target)
|
||||||
|
}
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
window.liveReloader = reloader
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
32
assets/tsconfig.json
Normal file
32
assets/tsconfig.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// This file is needed on most editors to enable the intelligent autocompletion
|
||||||
|
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
|
||||||
|
//
|
||||||
|
// Note: This file assumes a basic esbuild setup without node_modules.
|
||||||
|
// We include a generic paths alias to deps to mimic how esbuild resolves
|
||||||
|
// the Phoenix and LiveView JavaScript assets.
|
||||||
|
// If you have a package.json in your project, you should remove the
|
||||||
|
// paths configuration and instead add the phoenix dependencies to the
|
||||||
|
// dependencies section of your package.json:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// ...
|
||||||
|
// "dependencies": {
|
||||||
|
// ...,
|
||||||
|
// "phoenix": "../deps/phoenix",
|
||||||
|
// "phoenix_html": "../deps/phoenix_html",
|
||||||
|
// "phoenix_live_view": "../deps/phoenix_live_view"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Feel free to adjust this configuration however you need.
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"*": ["../deps/*"]
|
||||||
|
},
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["js/**/*"]
|
||||||
|
}
|
||||||
124
assets/vendor/daisyui-theme.js
vendored
Normal file
124
assets/vendor/daisyui-theme.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1031
assets/vendor/daisyui.js
vendored
Normal file
1031
assets/vendor/daisyui.js
vendored
Normal file
File diff suppressed because one or more lines are too long
43
assets/vendor/heroicons.js
vendored
Normal file
43
assets/vendor/heroicons.js
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
const plugin = require("tailwindcss/plugin")
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
module.exports = plugin(function({matchComponents, theme}) {
|
||||||
|
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
|
||||||
|
let values = {}
|
||||||
|
let icons = [
|
||||||
|
["", "/24/outline"],
|
||||||
|
["-solid", "/24/solid"],
|
||||||
|
["-mini", "/20/solid"],
|
||||||
|
["-micro", "/16/solid"]
|
||||||
|
]
|
||||||
|
icons.forEach(([suffix, dir]) => {
|
||||||
|
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||||
|
let name = path.basename(file, ".svg") + suffix
|
||||||
|
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
matchComponents({
|
||||||
|
"hero": ({name, fullPath}) => {
|
||||||
|
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||||
|
content = encodeURIComponent(content)
|
||||||
|
let size = theme("spacing.6")
|
||||||
|
if (name.endsWith("-mini")) {
|
||||||
|
size = theme("spacing.5")
|
||||||
|
} else if (name.endsWith("-micro")) {
|
||||||
|
size = theme("spacing.4")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||||
|
"-webkit-mask": `var(--hero-${name})`,
|
||||||
|
"mask": `var(--hero-${name})`,
|
||||||
|
"mask-repeat": "no-repeat",
|
||||||
|
"background-color": "currentColor",
|
||||||
|
"vertical-align": "middle",
|
||||||
|
"display": "inline-block",
|
||||||
|
"width": size,
|
||||||
|
"height": size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {values})
|
||||||
|
})
|
||||||
138
assets/vendor/topbar.js
vendored
Normal file
138
assets/vendor/topbar.js
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* @license MIT
|
||||||
|
* topbar 3.0.0
|
||||||
|
* http://buunguyen.github.io/topbar
|
||||||
|
* Copyright (c) 2024 Buu Nguyen
|
||||||
|
*/
|
||||||
|
(function (window, document) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var canvas,
|
||||||
|
currentProgress,
|
||||||
|
showing,
|
||||||
|
progressTimerId = null,
|
||||||
|
fadeTimerId = null,
|
||||||
|
delayTimerId = null,
|
||||||
|
addEvent = function (elem, type, handler) {
|
||||||
|
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||||
|
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||||
|
else elem["on" + type] = handler;
|
||||||
|
},
|
||||||
|
options = {
|
||||||
|
autoRun: true,
|
||||||
|
barThickness: 3,
|
||||||
|
barColors: {
|
||||||
|
0: "rgba(26, 188, 156, .9)",
|
||||||
|
".25": "rgba(52, 152, 219, .9)",
|
||||||
|
".50": "rgba(241, 196, 15, .9)",
|
||||||
|
".75": "rgba(230, 126, 34, .9)",
|
||||||
|
"1.0": "rgba(211, 84, 0, .9)",
|
||||||
|
},
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowColor: "rgba(0, 0, 0, .6)",
|
||||||
|
className: null,
|
||||||
|
},
|
||||||
|
repaint = function () {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = options.barThickness * 5; // need space for shadow
|
||||||
|
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
ctx.shadowBlur = options.shadowBlur;
|
||||||
|
ctx.shadowColor = options.shadowColor;
|
||||||
|
|
||||||
|
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||||
|
for (var stop in options.barColors)
|
||||||
|
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||||
|
ctx.lineWidth = options.barThickness;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, options.barThickness / 2);
|
||||||
|
ctx.lineTo(
|
||||||
|
Math.ceil(currentProgress * canvas.width),
|
||||||
|
options.barThickness / 2
|
||||||
|
);
|
||||||
|
ctx.strokeStyle = lineGradient;
|
||||||
|
ctx.stroke();
|
||||||
|
},
|
||||||
|
createCanvas = function () {
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
var style = canvas.style;
|
||||||
|
style.position = "fixed";
|
||||||
|
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||||
|
style.zIndex = 100001;
|
||||||
|
style.display = "none";
|
||||||
|
if (options.className) canvas.classList.add(options.className);
|
||||||
|
addEvent(window, "resize", repaint);
|
||||||
|
},
|
||||||
|
topbar = {
|
||||||
|
config: function (opts) {
|
||||||
|
for (var key in opts)
|
||||||
|
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||||
|
},
|
||||||
|
show: function (delay) {
|
||||||
|
if (showing) return;
|
||||||
|
if (delay) {
|
||||||
|
if (delayTimerId) return;
|
||||||
|
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||||
|
} else {
|
||||||
|
showing = true;
|
||||||
|
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||||
|
if (!canvas) createCanvas();
|
||||||
|
if (!canvas.parentElement) document.body.appendChild(canvas);
|
||||||
|
canvas.style.opacity = 1;
|
||||||
|
canvas.style.display = "block";
|
||||||
|
topbar.progress(0);
|
||||||
|
if (options.autoRun) {
|
||||||
|
(function loop() {
|
||||||
|
progressTimerId = window.requestAnimationFrame(loop);
|
||||||
|
topbar.progress(
|
||||||
|
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progress: function (to) {
|
||||||
|
if (typeof to === "undefined") return currentProgress;
|
||||||
|
if (typeof to === "string") {
|
||||||
|
to =
|
||||||
|
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||||
|
? currentProgress
|
||||||
|
: 0) + parseFloat(to);
|
||||||
|
}
|
||||||
|
currentProgress = to > 1 ? 1 : to;
|
||||||
|
repaint();
|
||||||
|
return currentProgress;
|
||||||
|
},
|
||||||
|
hide: function () {
|
||||||
|
clearTimeout(delayTimerId);
|
||||||
|
delayTimerId = null;
|
||||||
|
if (!showing) return;
|
||||||
|
showing = false;
|
||||||
|
if (progressTimerId != null) {
|
||||||
|
window.cancelAnimationFrame(progressTimerId);
|
||||||
|
progressTimerId = null;
|
||||||
|
}
|
||||||
|
(function loop() {
|
||||||
|
if (topbar.progress("+.1") >= 1) {
|
||||||
|
canvas.style.opacity -= 0.05;
|
||||||
|
if (canvas.style.opacity <= 0.05) {
|
||||||
|
canvas.style.display = "none";
|
||||||
|
fadeTimerId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fadeTimerId = window.requestAnimationFrame(loop);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module === "object" && typeof module.exports === "object") {
|
||||||
|
module.exports = topbar;
|
||||||
|
} else if (typeof define === "function" && define.amd) {
|
||||||
|
define(function () {
|
||||||
|
return topbar;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.topbar = topbar;
|
||||||
|
}
|
||||||
|
}.call(this, window, document));
|
||||||
65
config/config.exs
Normal file
65
config/config.exs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# This file is responsible for configuring your application
|
||||||
|
# and its dependencies with the aid of the Config module.
|
||||||
|
#
|
||||||
|
# This configuration file is loaded before any dependency and
|
||||||
|
# is restricted to this project.
|
||||||
|
|
||||||
|
# General application configuration
|
||||||
|
import Config
|
||||||
|
|
||||||
|
config :my_first_elixir_vibe_code,
|
||||||
|
ecto_repos: [MyFirstElixirVibeCode.Repo],
|
||||||
|
generators: [timestamp_type: :utc_datetime]
|
||||||
|
|
||||||
|
# Configures the endpoint
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
url: [host: "localhost"],
|
||||||
|
adapter: Bandit.PhoenixAdapter,
|
||||||
|
render_errors: [
|
||||||
|
formats: [html: MyFirstElixirVibeCodeWeb.ErrorHTML, json: MyFirstElixirVibeCodeWeb.ErrorJSON],
|
||||||
|
layout: false
|
||||||
|
],
|
||||||
|
pubsub_server: MyFirstElixirVibeCode.PubSub,
|
||||||
|
live_view: [signing_salt: "OuIDOGBt"]
|
||||||
|
|
||||||
|
# Configures the mailer
|
||||||
|
#
|
||||||
|
# By default it uses the "Local" adapter which stores the emails
|
||||||
|
# locally. You can see the emails in your browser, at "/dev/mailbox".
|
||||||
|
#
|
||||||
|
# For production it's recommended to configure a different adapter
|
||||||
|
# at the `config/runtime.exs`.
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Mailer, adapter: Swoosh.Adapters.Local
|
||||||
|
|
||||||
|
# Configure esbuild (the version is required)
|
||||||
|
config :esbuild,
|
||||||
|
version: "0.25.4",
|
||||||
|
my_first_elixir_vibe_code: [
|
||||||
|
args:
|
||||||
|
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Configure tailwind (the version is required)
|
||||||
|
config :tailwind,
|
||||||
|
version: "4.1.7",
|
||||||
|
my_first_elixir_vibe_code: [
|
||||||
|
args: ~w(
|
||||||
|
--input=assets/css/app.css
|
||||||
|
--output=priv/static/assets/css/app.css
|
||||||
|
),
|
||||||
|
cd: Path.expand("..", __DIR__)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Configures Elixir's Logger
|
||||||
|
config :logger, :default_formatter,
|
||||||
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
metadata: [:request_id]
|
||||||
|
|
||||||
|
# Use Jason for JSON parsing in Phoenix
|
||||||
|
config :phoenix, :json_library, Jason
|
||||||
|
|
||||||
|
# Import environment specific config. This must remain at the bottom
|
||||||
|
# of this file so it overrides the configuration defined above.
|
||||||
|
import_config "#{config_env()}.exs"
|
||||||
89
config/dev.exs
Normal file
89
config/dev.exs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import Config
|
||||||
|
|
||||||
|
# Configure your database
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Repo,
|
||||||
|
username: "kevinsivic",
|
||||||
|
password: "",
|
||||||
|
hostname: "localhost",
|
||||||
|
database: "my_first_elixir_vibe_code_dev",
|
||||||
|
stacktrace: true,
|
||||||
|
show_sensitive_data_on_connection_error: true,
|
||||||
|
pool_size: 10
|
||||||
|
|
||||||
|
# For development, we disable any cache and enable
|
||||||
|
# debugging and code reloading.
|
||||||
|
#
|
||||||
|
# The watchers configuration can be used to run external
|
||||||
|
# watchers to your application. For example, we can use it
|
||||||
|
# to bundle .js and .css sources.
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
# Binding to loopback ipv4 address prevents access from other machines.
|
||||||
|
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||||
|
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
|
||||||
|
check_origin: false,
|
||||||
|
code_reloader: true,
|
||||||
|
debug_errors: true,
|
||||||
|
secret_key_base: "WVwNLGOP5SozZsdCz2RcJfF4+RMBAI8v67JpNO2iIUe6oTIQ8BSZVafUL8Z9hSUs",
|
||||||
|
watchers: [
|
||||||
|
esbuild:
|
||||||
|
{Esbuild, :install_and_run, [:my_first_elixir_vibe_code, ~w(--sourcemap=inline --watch)]},
|
||||||
|
tailwind: {Tailwind, :install_and_run, [:my_first_elixir_vibe_code, ~w(--watch)]}
|
||||||
|
]
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# In order to use HTTPS in development, a self-signed
|
||||||
|
# certificate can be generated by running the following
|
||||||
|
# Mix task:
|
||||||
|
#
|
||||||
|
# mix phx.gen.cert
|
||||||
|
#
|
||||||
|
# Run `mix help phx.gen.cert` for more information.
|
||||||
|
#
|
||||||
|
# The `http:` config above can be replaced with:
|
||||||
|
#
|
||||||
|
# https: [
|
||||||
|
# port: 4001,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||||
|
# certfile: "priv/cert/selfsigned.pem"
|
||||||
|
# ],
|
||||||
|
#
|
||||||
|
# If desired, both `http:` and `https:` keys can be
|
||||||
|
# configured to run both http and https servers on
|
||||||
|
# different ports.
|
||||||
|
|
||||||
|
# Watch static and templates for browser reloading.
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
live_reload: [
|
||||||
|
web_console_logger: true,
|
||||||
|
patterns: [
|
||||||
|
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||||
|
~r"priv/gettext/.*(po)$",
|
||||||
|
~r"lib/my_first_elixir_vibe_code_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Enable dev routes for dashboard and mailbox
|
||||||
|
config :my_first_elixir_vibe_code, dev_routes: true
|
||||||
|
|
||||||
|
# Do not include metadata nor timestamps in development logs
|
||||||
|
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||||
|
|
||||||
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
|
# in production as building large stacktraces may be expensive.
|
||||||
|
config :phoenix, :stacktrace_depth, 20
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster development compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
|
config :phoenix_live_view,
|
||||||
|
# Include debug annotations and locations in rendered markup.
|
||||||
|
# Changing this configuration will require mix clean and a full recompile.
|
||||||
|
debug_heex_annotations: true,
|
||||||
|
debug_attributes: true,
|
||||||
|
# Enable helpful, but potentially expensive runtime checks
|
||||||
|
enable_expensive_runtime_checks: true
|
||||||
|
|
||||||
|
# Disable swoosh api client as it is only required for production adapters.
|
||||||
|
config :swoosh, :api_client, false
|
||||||
21
config/prod.exs
Normal file
21
config/prod.exs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Config
|
||||||
|
|
||||||
|
# Note we also include the path to a cache manifest
|
||||||
|
# containing the digested version of static files. This
|
||||||
|
# manifest is generated by the `mix assets.deploy` task,
|
||||||
|
# which you should run after static files are built and
|
||||||
|
# before starting your production server.
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
cache_static_manifest: "priv/static/cache_manifest.json"
|
||||||
|
|
||||||
|
# Configures Swoosh API Client
|
||||||
|
config :swoosh, api_client: Swoosh.ApiClient.Req
|
||||||
|
|
||||||
|
# Disable Swoosh Local Memory Storage
|
||||||
|
config :swoosh, local: false
|
||||||
|
|
||||||
|
# Do not print debug messages in production
|
||||||
|
config :logger, level: :info
|
||||||
|
|
||||||
|
# Runtime production configuration, including reading
|
||||||
|
# of environment variables, is done on config/runtime.exs.
|
||||||
120
config/runtime.exs
Normal file
120
config/runtime.exs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import Config
|
||||||
|
|
||||||
|
# config/runtime.exs is executed for all environments, including
|
||||||
|
# during releases. It is executed after compilation and before the
|
||||||
|
# system starts, so it is typically used to load production configuration
|
||||||
|
# and secrets from environment variables or elsewhere. Do not define
|
||||||
|
# any compile-time configuration in here, as it won't be applied.
|
||||||
|
# The block below contains prod specific runtime configuration.
|
||||||
|
|
||||||
|
# ## Using releases
|
||||||
|
#
|
||||||
|
# If you use `mix release`, you need to explicitly enable the server
|
||||||
|
# by passing the PHX_SERVER=true when you start it:
|
||||||
|
#
|
||||||
|
# PHX_SERVER=true bin/my_first_elixir_vibe_code start
|
||||||
|
#
|
||||||
|
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
|
||||||
|
# script that automatically sets the env var above.
|
||||||
|
if System.get_env("PHX_SERVER") do
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint, server: true
|
||||||
|
end
|
||||||
|
|
||||||
|
if config_env() == :prod do
|
||||||
|
database_url =
|
||||||
|
System.get_env("DATABASE_URL") ||
|
||||||
|
raise """
|
||||||
|
environment variable DATABASE_URL is missing.
|
||||||
|
For example: ecto://USER:PASS@HOST/DATABASE
|
||||||
|
"""
|
||||||
|
|
||||||
|
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||||
|
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Repo,
|
||||||
|
# ssl: true,
|
||||||
|
url: database_url,
|
||||||
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||||
|
# For machines with several cores, consider starting multiple pools of `pool_size`
|
||||||
|
# pool_count: 4,
|
||||||
|
socket_options: maybe_ipv6
|
||||||
|
|
||||||
|
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||||
|
# A default value is used in config/dev.exs and config/test.exs but you
|
||||||
|
# want to use a different value for prod and you most likely don't want
|
||||||
|
# to check this value into version control, so we use an environment
|
||||||
|
# variable instead.
|
||||||
|
secret_key_base =
|
||||||
|
System.get_env("SECRET_KEY_BASE") ||
|
||||||
|
raise """
|
||||||
|
environment variable SECRET_KEY_BASE is missing.
|
||||||
|
You can generate one by calling: mix phx.gen.secret
|
||||||
|
"""
|
||||||
|
|
||||||
|
host = System.get_env("PHX_HOST") || "example.com"
|
||||||
|
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||||
|
|
||||||
|
config :my_first_elixir_vibe_code, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||||
|
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
url: [host: host, port: port, scheme: "http"],
|
||||||
|
http: [
|
||||||
|
# Enable IPv6 and bind on all interfaces.
|
||||||
|
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||||
|
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||||
|
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||||
|
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||||
|
port: port
|
||||||
|
],
|
||||||
|
secret_key_base: secret_key_base,
|
||||||
|
server: true
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# To get SSL working, you will need to add the `https` key
|
||||||
|
# to your endpoint configuration:
|
||||||
|
#
|
||||||
|
# config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
# https: [
|
||||||
|
# ...,
|
||||||
|
# port: 443,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||||
|
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# The `cipher_suite` is set to `:strong` to support only the
|
||||||
|
# latest and more secure SSL ciphers. This means old browsers
|
||||||
|
# and clients may not be supported. You can set it to
|
||||||
|
# `:compatible` for wider support.
|
||||||
|
#
|
||||||
|
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||||
|
# and cert in disk or a relative path inside priv, for example
|
||||||
|
# "priv/ssl/server.key". For all supported SSL configuration
|
||||||
|
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||||
|
#
|
||||||
|
# We also recommend setting `force_ssl` in your config/prod.exs,
|
||||||
|
# ensuring no data is ever sent via http, always redirecting to https:
|
||||||
|
#
|
||||||
|
# config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
# force_ssl: [hsts: true]
|
||||||
|
#
|
||||||
|
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||||
|
|
||||||
|
# ## Configuring the mailer
|
||||||
|
#
|
||||||
|
# In production you need to configure the mailer to use a different adapter.
|
||||||
|
# Here is an example configuration for Mailgun:
|
||||||
|
#
|
||||||
|
# config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Mailer,
|
||||||
|
# adapter: Swoosh.Adapters.Mailgun,
|
||||||
|
# api_key: System.get_env("MAILGUN_API_KEY"),
|
||||||
|
# domain: System.get_env("MAILGUN_DOMAIN")
|
||||||
|
#
|
||||||
|
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
|
||||||
|
# and Finch out-of-the-box. This configuration is typically done at
|
||||||
|
# compile-time in your config/prod.exs:
|
||||||
|
#
|
||||||
|
# config :swoosh, :api_client, Swoosh.ApiClient.Req
|
||||||
|
#
|
||||||
|
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||||
|
end
|
||||||
37
config/test.exs
Normal file
37
config/test.exs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import Config
|
||||||
|
|
||||||
|
# Configure your database
|
||||||
|
#
|
||||||
|
# The MIX_TEST_PARTITION environment variable can be used
|
||||||
|
# to provide built-in test partitioning in CI environment.
|
||||||
|
# Run `mix help test` for more information.
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Repo,
|
||||||
|
username: "postgres",
|
||||||
|
password: "postgres",
|
||||||
|
hostname: "localhost",
|
||||||
|
database: "my_first_elixir_vibe_code_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||||
|
pool: Ecto.Adapters.SQL.Sandbox,
|
||||||
|
pool_size: System.schedulers_online() * 2
|
||||||
|
|
||||||
|
# We don't run a server during test. If one is required,
|
||||||
|
# you can enable the server option below.
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||||
|
secret_key_base: "lytCVOsnu1RJs4fVgxkwUdnBrMZZT9HN9Me8jY1+DEdwj3MS54TZq+nHIna7f0iM",
|
||||||
|
server: false
|
||||||
|
|
||||||
|
# In test we don't send emails
|
||||||
|
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Mailer, adapter: Swoosh.Adapters.Test
|
||||||
|
|
||||||
|
# Disable swoosh api client as it is only required for production adapters
|
||||||
|
config :swoosh, :api_client, false
|
||||||
|
|
||||||
|
# Print only warnings and errors during test
|
||||||
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster test compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
|
# Enable helpful, but potentially expensive runtime checks
|
||||||
|
config :phoenix_live_view,
|
||||||
|
enable_expensive_runtime_checks: true
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: my_first_elixir_vibe_code_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "ecto://postgres:postgres@db/my_first_elixir_vibe_code_dev"
|
||||||
|
SECRET_KEY_BASE: "PNosxXTGA8DhjwcE5QWJkJZr400AFXjIWu27dEFQg/QJE3RyCna6HePec1Sjgz6W"
|
||||||
|
PHX_HOST: "localhost"
|
||||||
|
PORT: "4000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./priv/static:/app/priv/static
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
21
entrypoint.sh
Normal file
21
entrypoint.sh
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Wait for Postgres to be ready
|
||||||
|
echo "Waiting for postgres..."
|
||||||
|
while ! nc -z db 5432; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
# Create database if it doesn't exist
|
||||||
|
bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.create_db()"
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.migrate()"
|
||||||
|
|
||||||
|
# Create admin user if needed
|
||||||
|
bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.seed_admin()"
|
||||||
|
|
||||||
|
# Start the Phoenix server
|
||||||
|
exec "$@"
|
||||||
9
lib/my_first_elixir_vibe_code.ex
Normal file
9
lib/my_first_elixir_vibe_code.ex
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule MyFirstElixirVibeCode do
|
||||||
|
@moduledoc """
|
||||||
|
MyFirstElixirVibeCode keeps the contexts that define your domain
|
||||||
|
and business logic.
|
||||||
|
|
||||||
|
Contexts are also responsible for managing your data, regardless
|
||||||
|
if it comes from the database, an external API or others.
|
||||||
|
"""
|
||||||
|
end
|
||||||
337
lib/my_first_elixir_vibe_code/accounts.ex
Normal file
337
lib/my_first_elixir_vibe_code/accounts.ex
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Accounts do
|
||||||
|
@moduledoc """
|
||||||
|
The Accounts context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias MyFirstElixirVibeCode.Repo
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.Client
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of clients.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> list_clients()
|
||||||
|
[%Client{}, ...]
|
||||||
|
|
||||||
|
"""
|
||||||
|
def list_clients do
|
||||||
|
Repo.all(Client)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single client.
|
||||||
|
|
||||||
|
Raises `Ecto.NoResultsError` if the Client does not exist.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_client!(123)
|
||||||
|
%Client{}
|
||||||
|
|
||||||
|
iex> get_client!(456)
|
||||||
|
** (Ecto.NoResultsError)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_client!(id), do: Repo.get!(Client, id)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a client.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> create_client(%{field: value})
|
||||||
|
{:ok, %Client{}}
|
||||||
|
|
||||||
|
iex> create_client(%{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def create_client(attrs) do
|
||||||
|
%Client{}
|
||||||
|
|> Client.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a client.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> update_client(client, %{field: new_value})
|
||||||
|
{:ok, %Client{}}
|
||||||
|
|
||||||
|
iex> update_client(client, %{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def update_client(%Client{} = client, attrs) do
|
||||||
|
client
|
||||||
|
|> Client.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a client.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> delete_client(client)
|
||||||
|
{:ok, %Client{}}
|
||||||
|
|
||||||
|
iex> delete_client(client)
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def delete_client(%Client{} = client) do
|
||||||
|
Repo.delete(client)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking client changes.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_client(client)
|
||||||
|
%Ecto.Changeset{data: %Client{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_client(%Client{} = client, attrs \\ %{}) do
|
||||||
|
Client.changeset(client, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.Contractor
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of contractors.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> list_contractors()
|
||||||
|
[%Contractor{}, ...]
|
||||||
|
|
||||||
|
"""
|
||||||
|
def list_contractors do
|
||||||
|
Repo.all(Contractor)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single contractor.
|
||||||
|
|
||||||
|
Raises `Ecto.NoResultsError` if the Contractor does not exist.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_contractor!(123)
|
||||||
|
%Contractor{}
|
||||||
|
|
||||||
|
iex> get_contractor!(456)
|
||||||
|
** (Ecto.NoResultsError)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_contractor!(id), do: Repo.get!(Contractor, id)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a contractor.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> create_contractor(%{field: value})
|
||||||
|
{:ok, %Contractor{}}
|
||||||
|
|
||||||
|
iex> create_contractor(%{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def create_contractor(attrs) do
|
||||||
|
%Contractor{}
|
||||||
|
|> Contractor.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a contractor.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> update_contractor(contractor, %{field: new_value})
|
||||||
|
{:ok, %Contractor{}}
|
||||||
|
|
||||||
|
iex> update_contractor(contractor, %{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def update_contractor(%Contractor{} = contractor, attrs) do
|
||||||
|
contractor
|
||||||
|
|> Contractor.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a contractor.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> delete_contractor(contractor)
|
||||||
|
{:ok, %Contractor{}}
|
||||||
|
|
||||||
|
iex> delete_contractor(contractor)
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def delete_contractor(%Contractor{} = contractor) do
|
||||||
|
Repo.delete(contractor)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking contractor changes.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_contractor(contractor)
|
||||||
|
%Ecto.Changeset{data: %Contractor{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_contractor(%Contractor{} = contractor, attrs \\ %{}) do
|
||||||
|
Contractor.changeset(contractor, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Registers a new contractor with required documents.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> register_contractor(%{field: value})
|
||||||
|
{:ok, %Contractor{}}
|
||||||
|
|
||||||
|
iex> register_contractor(%{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def register_contractor(attrs) do
|
||||||
|
%Contractor{}
|
||||||
|
|> Contractor.registration_changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking contractor registration changes.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_contractor_registration(contractor)
|
||||||
|
%Ecto.Changeset{data: %Contractor{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_contractor_registration(%Contractor{} = contractor, attrs \\ %{}) do
|
||||||
|
Contractor.registration_changeset(contractor, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.User
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of users.
|
||||||
|
"""
|
||||||
|
def list_users do
|
||||||
|
Repo.all(User)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single user.
|
||||||
|
|
||||||
|
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||||
|
"""
|
||||||
|
def get_user!(id), do: Repo.get!(User, id)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a user by email.
|
||||||
|
"""
|
||||||
|
def get_user_by_email(email) when is_binary(email) do
|
||||||
|
Repo.get_by(User, email: email)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a user by email and password.
|
||||||
|
"""
|
||||||
|
def get_user_by_email_and_password(email, password)
|
||||||
|
when is_binary(email) and is_binary(password) do
|
||||||
|
user = Repo.get_by(User, email: email)
|
||||||
|
if User.valid_password?(user, password), do: user
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a user by username and password (for admin login).
|
||||||
|
"""
|
||||||
|
def get_user_by_username_and_password(username, password)
|
||||||
|
when is_binary(username) and is_binary(password) do
|
||||||
|
user = Repo.get_by(User, username: username)
|
||||||
|
if user && user.is_admin && User.valid_password?(user, password), do: user
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a user.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> create_user(%{field: value})
|
||||||
|
{:ok, %User{}}
|
||||||
|
|
||||||
|
iex> create_user(%{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def create_user(attrs \\ %{}) do
|
||||||
|
%User{}
|
||||||
|
|> User.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a user.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> update_user(user, %{field: new_value})
|
||||||
|
{:ok, %User{}}
|
||||||
|
|
||||||
|
iex> update_user(user, %{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def update_user(%User{} = user, attrs) do
|
||||||
|
user
|
||||||
|
|> User.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a user.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> delete_user(user)
|
||||||
|
{:ok, %User{}}
|
||||||
|
|
||||||
|
iex> delete_user(user)
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def delete_user(%User{} = user) do
|
||||||
|
Repo.delete(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking user changes.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_user(user)
|
||||||
|
%Ecto.Changeset{data: %User{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_user(%User{} = user, attrs \\ %{}) do
|
||||||
|
User.changeset(user, attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
19
lib/my_first_elixir_vibe_code/accounts/client.ex
Normal file
19
lib/my_first_elixir_vibe_code/accounts/client.ex
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Accounts.Client do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "clients" do
|
||||||
|
field :name, :string
|
||||||
|
field :email, :string
|
||||||
|
field :company_name, :string
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(client, attrs) do
|
||||||
|
client
|
||||||
|
|> cast(attrs, [:name, :email, :company_name])
|
||||||
|
|> validate_required([:name, :email, :company_name])
|
||||||
|
end
|
||||||
|
end
|
||||||
33
lib/my_first_elixir_vibe_code/accounts/contractor.ex
Normal file
33
lib/my_first_elixir_vibe_code/accounts/contractor.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Accounts.Contractor do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "contractors" do
|
||||||
|
field :name, :string
|
||||||
|
field :email, :string
|
||||||
|
field :company_name, :string
|
||||||
|
field :municipal_registration_proof, :string
|
||||||
|
field :insurance_proof, :string
|
||||||
|
field :registration_status, :string, default: "pending"
|
||||||
|
field :verified_at, :utc_datetime
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(contractor, attrs) do
|
||||||
|
contractor
|
||||||
|
|> cast(attrs, [:name, :email, :company_name])
|
||||||
|
|> validate_required([:name, :email, :company_name])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def registration_changeset(contractor, attrs) do
|
||||||
|
contractor
|
||||||
|
|> cast(attrs, [:name, :email, :company_name, :municipal_registration_proof, :insurance_proof])
|
||||||
|
|> validate_required([:name, :email, :company_name, :municipal_registration_proof, :insurance_proof])
|
||||||
|
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email")
|
||||||
|
|> unique_constraint(:email)
|
||||||
|
|> put_change(:registration_status, "pending")
|
||||||
|
end
|
||||||
|
end
|
||||||
84
lib/my_first_elixir_vibe_code/accounts/user.ex
Normal file
84
lib/my_first_elixir_vibe_code/accounts/user.ex
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Accounts.User do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "users" do
|
||||||
|
field :email, :string
|
||||||
|
field :username, :string
|
||||||
|
field :password, :string, virtual: true, redact: true
|
||||||
|
field :password_hash, :string
|
||||||
|
field :is_admin, :boolean, default: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(user, attrs) do
|
||||||
|
user
|
||||||
|
|> cast(attrs, [:email, :username, :password, :is_admin])
|
||||||
|
|> validate_required([:password])
|
||||||
|
|> validate_email_or_username()
|
||||||
|
|> validate_password()
|
||||||
|
|> put_password_hash()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_email_or_username(changeset) do
|
||||||
|
is_admin = get_field(changeset, :is_admin)
|
||||||
|
|
||||||
|
changeset
|
||||||
|
|> then(fn cs ->
|
||||||
|
if is_admin do
|
||||||
|
cs
|
||||||
|
|> validate_required([:username])
|
||||||
|
|> validate_length(:username, min: 3, max: 20)
|
||||||
|
|> unsafe_validate_unique(:username, MyFirstElixirVibeCode.Repo)
|
||||||
|
|> unique_constraint(:username)
|
||||||
|
else
|
||||||
|
cs
|
||||||
|
|> validate_required([:email])
|
||||||
|
|> validate_email()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_email(changeset) do
|
||||||
|
changeset
|
||||||
|
|> validate_required([:email])
|
||||||
|
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|
||||||
|
|> validate_length(:email, max: 160)
|
||||||
|
|> unsafe_validate_unique(:email, MyFirstElixirVibeCode.Repo)
|
||||||
|
|> unique_constraint(:email)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_password(changeset) do
|
||||||
|
changeset
|
||||||
|
|> validate_required([:password])
|
||||||
|
|> validate_length(:password, min: 6, max: 72)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_password_hash(changeset) do
|
||||||
|
case changeset do
|
||||||
|
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
|
||||||
|
change(changeset, password_hash: Bcrypt.hash_pwd_salt(password))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Verifies the password.
|
||||||
|
|
||||||
|
If there is no user or the user doesn't have a password, we call
|
||||||
|
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||||
|
"""
|
||||||
|
def valid_password?(%__MODULE__{password_hash: password_hash}, password)
|
||||||
|
when is_binary(password_hash) and byte_size(password) > 0 do
|
||||||
|
Bcrypt.verify_pass(password, password_hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_password?(_, _) do
|
||||||
|
Bcrypt.no_user_verify()
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
34
lib/my_first_elixir_vibe_code/application.ex
Normal file
34
lib/my_first_elixir_vibe_code/application.ex
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Application do
|
||||||
|
# See https://hexdocs.pm/elixir/Application.html
|
||||||
|
# for more information on OTP Applications
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
MyFirstElixirVibeCodeWeb.Telemetry,
|
||||||
|
MyFirstElixirVibeCode.Repo,
|
||||||
|
{DNSCluster, query: Application.get_env(:my_first_elixir_vibe_code, :dns_cluster_query) || :ignore},
|
||||||
|
{Phoenix.PubSub, name: MyFirstElixirVibeCode.PubSub},
|
||||||
|
# Start a worker by calling: MyFirstElixirVibeCode.Worker.start_link(arg)
|
||||||
|
# {MyFirstElixirVibeCode.Worker, arg},
|
||||||
|
# Start to serve requests, typically the last entry
|
||||||
|
MyFirstElixirVibeCodeWeb.Endpoint
|
||||||
|
]
|
||||||
|
|
||||||
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
# for other strategies and supported options
|
||||||
|
opts = [strategy: :one_for_one, name: MyFirstElixirVibeCode.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tell Phoenix to update the endpoint configuration
|
||||||
|
# whenever the application is updated.
|
||||||
|
@impl true
|
||||||
|
def config_change(changed, _new, removed) do
|
||||||
|
MyFirstElixirVibeCodeWeb.Endpoint.config_change(changed, removed)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
3
lib/my_first_elixir_vibe_code/mailer.ex
Normal file
3
lib/my_first_elixir_vibe_code/mailer.ex
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Mailer do
|
||||||
|
use Swoosh.Mailer, otp_app: :my_first_elixir_vibe_code
|
||||||
|
end
|
||||||
64
lib/my_first_elixir_vibe_code/release.ex
Normal file
64
lib/my_first_elixir_vibe_code/release.ex
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Release do
|
||||||
|
@moduledoc """
|
||||||
|
Used for executing DB release tasks when run in production without Mix
|
||||||
|
installed.
|
||||||
|
"""
|
||||||
|
@app :my_first_elixir_vibe_code
|
||||||
|
|
||||||
|
def create_db do
|
||||||
|
load_app()
|
||||||
|
|
||||||
|
for repo <- repos() do
|
||||||
|
case repo.__adapter__.storage_up(repo.config) do
|
||||||
|
:ok -> IO.puts("Database created for #{inspect(repo)}")
|
||||||
|
{:error, :already_up} -> IO.puts("Database already exists for #{inspect(repo)}")
|
||||||
|
{:error, term} -> IO.puts("Error creating database: #{inspect(term)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def migrate do
|
||||||
|
load_app()
|
||||||
|
|
||||||
|
for repo <- repos() do
|
||||||
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def rollback(repo, version) do
|
||||||
|
load_app()
|
||||||
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||||
|
end
|
||||||
|
|
||||||
|
def seed_admin do
|
||||||
|
load_app()
|
||||||
|
|
||||||
|
for repo <- repos() do
|
||||||
|
{:ok, _, _} =
|
||||||
|
Ecto.Migrator.with_repo(repo, fn _repo ->
|
||||||
|
# Check if admin user already exists
|
||||||
|
case MyFirstElixirVibeCode.Repo.get_by(MyFirstElixirVibeCode.Accounts.User, username: "admin") do
|
||||||
|
nil ->
|
||||||
|
# Create admin user
|
||||||
|
MyFirstElixirVibeCode.Accounts.create_user(%{
|
||||||
|
username: "admin",
|
||||||
|
password: "admin123",
|
||||||
|
is_admin: true
|
||||||
|
})
|
||||||
|
IO.puts("Admin user created (username: admin, password: admin123)")
|
||||||
|
|
||||||
|
_user ->
|
||||||
|
IO.puts("Admin user already exists")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repos do
|
||||||
|
Application.fetch_env!(@app, :ecto_repos)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_app do
|
||||||
|
Application.load(@app)
|
||||||
|
end
|
||||||
|
end
|
||||||
5
lib/my_first_elixir_vibe_code/repo.ex
Normal file
5
lib/my_first_elixir_vibe_code/repo.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo do
|
||||||
|
use Ecto.Repo,
|
||||||
|
otp_app: :my_first_elixir_vibe_code,
|
||||||
|
adapter: Ecto.Adapters.Postgres
|
||||||
|
end
|
||||||
133
lib/my_first_elixir_vibe_code/reviews.ex
Normal file
133
lib/my_first_elixir_vibe_code/reviews.ex
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Reviews do
|
||||||
|
@moduledoc """
|
||||||
|
The Reviews context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias MyFirstElixirVibeCode.Repo
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Reviews.Review
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of reviews.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> list_reviews()
|
||||||
|
[%Review{}, ...]
|
||||||
|
|
||||||
|
"""
|
||||||
|
def list_reviews do
|
||||||
|
Repo.all(Review)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Searches reviews by client name or address.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> search_reviews("john")
|
||||||
|
[%Review{}, ...]
|
||||||
|
|
||||||
|
iex> search_reviews("")
|
||||||
|
[%Review{}, ...]
|
||||||
|
|
||||||
|
"""
|
||||||
|
def search_reviews(""), do: list_reviews()
|
||||||
|
|
||||||
|
def search_reviews(query) when is_binary(query) do
|
||||||
|
search_term = "%#{query}%"
|
||||||
|
|
||||||
|
from(r in Review,
|
||||||
|
where:
|
||||||
|
ilike(r.client_first_name, ^search_term) or
|
||||||
|
ilike(r.client_last_name, ^search_term) or
|
||||||
|
ilike(r.client_street_address, ^search_term) or
|
||||||
|
ilike(r.client_city, ^search_term) or
|
||||||
|
ilike(r.client_state, ^search_term) or
|
||||||
|
ilike(r.client_zip, ^search_term)
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single review.
|
||||||
|
|
||||||
|
Raises `Ecto.NoResultsError` if the Review does not exist.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_review!(123)
|
||||||
|
%Review{}
|
||||||
|
|
||||||
|
iex> get_review!(456)
|
||||||
|
** (Ecto.NoResultsError)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_review!(id), do: Repo.get!(Review, id)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a review.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> create_review(%{field: value})
|
||||||
|
{:ok, %Review{}}
|
||||||
|
|
||||||
|
iex> create_review(%{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def create_review(attrs) do
|
||||||
|
%Review{}
|
||||||
|
|> Review.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a review.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> update_review(review, %{field: new_value})
|
||||||
|
{:ok, %Review{}}
|
||||||
|
|
||||||
|
iex> update_review(review, %{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def update_review(%Review{} = review, attrs) do
|
||||||
|
review
|
||||||
|
|> Review.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a review.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> delete_review(review)
|
||||||
|
{:ok, %Review{}}
|
||||||
|
|
||||||
|
iex> delete_review(review)
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def delete_review(%Review{} = review) do
|
||||||
|
Repo.delete(review)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking review changes.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_review(review)
|
||||||
|
%Ecto.Changeset{data: %Review{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_review(%Review{} = review, attrs \\ %{}) do
|
||||||
|
Review.changeset(review, attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
73
lib/my_first_elixir_vibe_code/reviews/review.ex
Normal file
73
lib/my_first_elixir_vibe_code/reviews/review.ex
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Reviews.Review do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "reviews" do
|
||||||
|
field :rating, :integer
|
||||||
|
field :title, :string
|
||||||
|
field :content, :string
|
||||||
|
field :project_type, :string
|
||||||
|
field :client_first_name, :string
|
||||||
|
field :client_last_name, :string
|
||||||
|
field :client_street_address, :string
|
||||||
|
field :client_city, :string
|
||||||
|
field :client_state, :string
|
||||||
|
field :client_zip, :string
|
||||||
|
field :client_country, :string
|
||||||
|
field :municipal_registration_proof, :string
|
||||||
|
field :work_permit_proof, :string
|
||||||
|
field :contractor_id, :id
|
||||||
|
field :client_id, :id
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(review, attrs) do
|
||||||
|
review
|
||||||
|
|> cast(attrs, [
|
||||||
|
:rating,
|
||||||
|
:title,
|
||||||
|
:content,
|
||||||
|
:project_type,
|
||||||
|
:client_first_name,
|
||||||
|
:client_last_name,
|
||||||
|
:client_street_address,
|
||||||
|
:client_city,
|
||||||
|
:client_state,
|
||||||
|
:client_zip,
|
||||||
|
:client_country,
|
||||||
|
:municipal_registration_proof,
|
||||||
|
:work_permit_proof
|
||||||
|
])
|
||||||
|
|> validate_required([
|
||||||
|
:rating,
|
||||||
|
:title,
|
||||||
|
:content,
|
||||||
|
:project_type,
|
||||||
|
:client_first_name,
|
||||||
|
:client_last_name,
|
||||||
|
:client_street_address,
|
||||||
|
:client_city,
|
||||||
|
:client_state,
|
||||||
|
:client_zip
|
||||||
|
])
|
||||||
|
|> validate_number(:rating, greater_than_or_equal_to: 1, less_than_or_equal_to: 5)
|
||||||
|
|> validate_at_least_one_document()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_at_least_one_document(changeset) do
|
||||||
|
municipal_proof = get_field(changeset, :municipal_registration_proof)
|
||||||
|
work_permit = get_field(changeset, :work_permit_proof)
|
||||||
|
|
||||||
|
if is_nil(municipal_proof) && is_nil(work_permit) do
|
||||||
|
add_error(
|
||||||
|
changeset,
|
||||||
|
:municipal_registration_proof,
|
||||||
|
"You must provide either municipal registration or work permit proof"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
114
lib/my_first_elixir_vibe_code_web.ex
Normal file
114
lib/my_first_elixir_vibe_code_web.ex
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb do
|
||||||
|
@moduledoc """
|
||||||
|
The entrypoint for defining your web interface, such
|
||||||
|
as controllers, components, channels, and so on.
|
||||||
|
|
||||||
|
This can be used in your application as:
|
||||||
|
|
||||||
|
use MyFirstElixirVibeCodeWeb, :controller
|
||||||
|
use MyFirstElixirVibeCodeWeb, :html
|
||||||
|
|
||||||
|
The definitions below will be executed for every controller,
|
||||||
|
component, etc, so keep them short and clean, focused
|
||||||
|
on imports, uses and aliases.
|
||||||
|
|
||||||
|
Do NOT define functions inside the quoted expressions
|
||||||
|
below. Instead, define additional modules and import
|
||||||
|
those modules here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||||
|
|
||||||
|
def router do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Router, helpers: false
|
||||||
|
|
||||||
|
# Import common connection and controller functions to use in pipelines
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
import Phoenix.LiveView.Router
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Channel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def controller do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Controller, formats: [:html, :json]
|
||||||
|
|
||||||
|
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
unquote(verified_routes())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_view do
|
||||||
|
quote do
|
||||||
|
use Phoenix.LiveView
|
||||||
|
|
||||||
|
unquote(html_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_component do
|
||||||
|
quote do
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
|
unquote(html_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def html do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
# Import convenience functions from controllers
|
||||||
|
import Phoenix.Controller,
|
||||||
|
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||||
|
|
||||||
|
# Include general helpers for rendering HTML
|
||||||
|
unquote(html_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp html_helpers do
|
||||||
|
quote do
|
||||||
|
# Translation
|
||||||
|
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
|
||||||
|
|
||||||
|
# HTML escaping functionality
|
||||||
|
import Phoenix.HTML
|
||||||
|
# Core UI components
|
||||||
|
import MyFirstElixirVibeCodeWeb.CoreComponents
|
||||||
|
|
||||||
|
# Common modules used in templates
|
||||||
|
alias Phoenix.LiveView.JS
|
||||||
|
alias MyFirstElixirVibeCodeWeb.Layouts
|
||||||
|
|
||||||
|
# Routes generation with the ~p sigil
|
||||||
|
unquote(verified_routes())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def verified_routes do
|
||||||
|
quote do
|
||||||
|
use Phoenix.VerifiedRoutes,
|
||||||
|
endpoint: MyFirstElixirVibeCodeWeb.Endpoint,
|
||||||
|
router: MyFirstElixirVibeCodeWeb.Router,
|
||||||
|
statics: MyFirstElixirVibeCodeWeb.static_paths()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
When used, dispatch to the appropriate controller/live_view/etc.
|
||||||
|
"""
|
||||||
|
defmacro __using__(which) when is_atom(which) do
|
||||||
|
apply(__MODULE__, which, [])
|
||||||
|
end
|
||||||
|
end
|
||||||
472
lib/my_first_elixir_vibe_code_web/components/core_components.ex
Normal file
472
lib/my_first_elixir_vibe_code_web/components/core_components.ex
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.CoreComponents do
|
||||||
|
@moduledoc """
|
||||||
|
Provides core UI components.
|
||||||
|
|
||||||
|
At first glance, this module may seem daunting, but its goal is to provide
|
||||||
|
core building blocks for your application, such as tables, forms, and
|
||||||
|
inputs. The components consist mostly of markup and are well-documented
|
||||||
|
with doc strings and declarative assigns. You may customize and style
|
||||||
|
them in any way you want, based on your application growth and needs.
|
||||||
|
|
||||||
|
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
|
||||||
|
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
|
||||||
|
and themes. Here are useful references:
|
||||||
|
|
||||||
|
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
|
||||||
|
started and see the available components.
|
||||||
|
|
||||||
|
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
|
||||||
|
we build on. You will use it for layout, sizing, flexbox, grid, and
|
||||||
|
spacing.
|
||||||
|
|
||||||
|
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||||||
|
|
||||||
|
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||||||
|
the component system used by Phoenix. Some components, such as `<.link>`
|
||||||
|
and `<.form>`, are defined there.
|
||||||
|
|
||||||
|
"""
|
||||||
|
use Phoenix.Component
|
||||||
|
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
|
||||||
|
|
||||||
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders flash notices.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.flash kind={:info} flash={@flash} />
|
||||||
|
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||||
|
"""
|
||||||
|
attr :id, :string, doc: "the optional id of flash container"
|
||||||
|
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||||
|
attr :title, :string, default: nil
|
||||||
|
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||||
|
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||||
|
|
||||||
|
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||||
|
|
||||||
|
def flash(assigns) do
|
||||||
|
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||||
|
id={@id}
|
||||||
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
|
role="alert"
|
||||||
|
class="toast toast-top toast-end z-50"
|
||||||
|
{@rest}
|
||||||
|
>
|
||||||
|
<div class={[
|
||||||
|
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||||
|
@kind == :info && "alert-info",
|
||||||
|
@kind == :error && "alert-error"
|
||||||
|
]}>
|
||||||
|
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||||
|
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p :if={@title} class="font-semibold">{@title}</p>
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||||
|
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a button with navigation support.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.button>Send!</.button>
|
||||||
|
<.button phx-click="go" variant="primary">Send!</.button>
|
||||||
|
<.button navigate={~p"/"}>Home</.button>
|
||||||
|
"""
|
||||||
|
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||||||
|
attr :class, :string
|
||||||
|
attr :variant, :string, values: ~w(primary)
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def button(%{rest: rest} = assigns) do
|
||||||
|
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assign_new(assigns, :class, fn ->
|
||||||
|
["btn", Map.fetch!(variants, assigns[:variant])]
|
||||||
|
end)
|
||||||
|
|
||||||
|
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||||
|
~H"""
|
||||||
|
<.link class={@class} {@rest}>
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</.link>
|
||||||
|
"""
|
||||||
|
else
|
||||||
|
~H"""
|
||||||
|
<button class={@class} {@rest}>
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders an input with label and error messages.
|
||||||
|
|
||||||
|
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||||
|
which is used to retrieve the input name, id, and values.
|
||||||
|
Otherwise all attributes may be passed explicitly.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
This function accepts all HTML input types, considering that:
|
||||||
|
|
||||||
|
* You may also set `type="select"` to render a `<select>` tag
|
||||||
|
|
||||||
|
* `type="checkbox"` is used exclusively to render boolean values
|
||||||
|
|
||||||
|
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||||
|
|
||||||
|
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||||
|
for more information. Unsupported types, such as hidden and radio,
|
||||||
|
are best written directly in your templates.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.input field={@form[:email]} type="email" />
|
||||||
|
<.input name="my-input" errors={["oh no!"]} />
|
||||||
|
"""
|
||||||
|
attr :id, :any, default: nil
|
||||||
|
attr :name, :any
|
||||||
|
attr :label, :string, default: nil
|
||||||
|
attr :value, :any
|
||||||
|
|
||||||
|
attr :type, :string,
|
||||||
|
default: "text",
|
||||||
|
values: ~w(checkbox color date datetime-local email file month number password
|
||||||
|
search select tel text textarea time url week)
|
||||||
|
|
||||||
|
attr :field, Phoenix.HTML.FormField,
|
||||||
|
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||||
|
|
||||||
|
attr :errors, :list, default: []
|
||||||
|
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||||
|
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||||
|
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||||
|
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||||
|
attr :class, :string, default: nil, doc: "the input class to use over defaults"
|
||||||
|
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
|
||||||
|
|
||||||
|
attr :rest, :global,
|
||||||
|
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||||
|
multiple pattern placeholder readonly required rows size step)
|
||||||
|
|
||||||
|
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||||
|
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||||
|
|
||||||
|
assigns
|
||||||
|
|> assign(field: nil, id: assigns.id || field.id)
|
||||||
|
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||||
|
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||||
|
|> assign_new(:value, fn -> field.value end)
|
||||||
|
|> input()
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "checkbox"} = assigns) do
|
||||||
|
assigns =
|
||||||
|
assign_new(assigns, :checked, fn ->
|
||||||
|
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||||
|
end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label>
|
||||||
|
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||||
|
<span class="label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={@id}
|
||||||
|
name={@name}
|
||||||
|
value="true"
|
||||||
|
checked={@checked}
|
||||||
|
class={@class || "checkbox checkbox-sm"}
|
||||||
|
{@rest}
|
||||||
|
/>{@label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "select"} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label>
|
||||||
|
<span :if={@label} class="label mb-1">{@label}</span>
|
||||||
|
<select
|
||||||
|
id={@id}
|
||||||
|
name={@name}
|
||||||
|
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
|
||||||
|
multiple={@multiple}
|
||||||
|
{@rest}
|
||||||
|
>
|
||||||
|
<option :if={@prompt} value="">{@prompt}</option>
|
||||||
|
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def input(%{type: "textarea"} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label>
|
||||||
|
<span :if={@label} class="label mb-1">{@label}</span>
|
||||||
|
<textarea
|
||||||
|
id={@id}
|
||||||
|
name={@name}
|
||||||
|
class={[
|
||||||
|
@class || "w-full textarea",
|
||||||
|
@errors != [] && (@error_class || "textarea-error")
|
||||||
|
]}
|
||||||
|
{@rest}
|
||||||
|
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||||
|
def input(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label>
|
||||||
|
<span :if={@label} class="label mb-1">{@label}</span>
|
||||||
|
<input
|
||||||
|
type={@type}
|
||||||
|
name={@name}
|
||||||
|
id={@id}
|
||||||
|
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||||
|
class={[
|
||||||
|
@class || "w-full input",
|
||||||
|
@errors != [] && (@error_class || "input-error")
|
||||||
|
]}
|
||||||
|
{@rest}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper used by inputs to generate form errors
|
||||||
|
defp error(assigns) do
|
||||||
|
~H"""
|
||||||
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</p>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a header with title.
|
||||||
|
"""
|
||||||
|
slot :inner_block, required: true
|
||||||
|
slot :subtitle
|
||||||
|
slot :actions
|
||||||
|
|
||||||
|
def header(assigns) do
|
||||||
|
~H"""
|
||||||
|
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-semibold leading-8">
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</h1>
|
||||||
|
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||||
|
{render_slot(@subtitle)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">{render_slot(@actions)}</div>
|
||||||
|
</header>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a table with generic styling.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.table id="users" rows={@users}>
|
||||||
|
<:col :let={user} label="id">{user.id}</:col>
|
||||||
|
<:col :let={user} label="username">{user.username}</:col>
|
||||||
|
</.table>
|
||||||
|
"""
|
||||||
|
attr :id, :string, required: true
|
||||||
|
attr :rows, :list, required: true
|
||||||
|
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||||
|
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||||
|
|
||||||
|
attr :row_item, :any,
|
||||||
|
default: &Function.identity/1,
|
||||||
|
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||||
|
|
||||||
|
slot :col, required: true do
|
||||||
|
attr :label, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||||
|
|
||||||
|
def table(assigns) do
|
||||||
|
assigns =
|
||||||
|
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||||
|
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th :for={col <- @col}>{col[:label]}</th>
|
||||||
|
<th :if={@action != []}>
|
||||||
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||||
|
<td
|
||||||
|
:for={col <- @col}
|
||||||
|
phx-click={@row_click && @row_click.(row)}
|
||||||
|
class={@row_click && "hover:cursor-pointer"}
|
||||||
|
>
|
||||||
|
{render_slot(col, @row_item.(row))}
|
||||||
|
</td>
|
||||||
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<%= for action <- @action do %>
|
||||||
|
{render_slot(action, @row_item.(row))}
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a data list.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.list>
|
||||||
|
<:item title="Title">{@post.title}</:item>
|
||||||
|
<:item title="Views">{@post.views}</:item>
|
||||||
|
</.list>
|
||||||
|
"""
|
||||||
|
slot :item, required: true do
|
||||||
|
attr :title, :string, required: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def list(assigns) do
|
||||||
|
~H"""
|
||||||
|
<ul class="list">
|
||||||
|
<li :for={item <- @item} class="list-row">
|
||||||
|
<div class="list-col-grow">
|
||||||
|
<div class="font-bold">{item.title}</div>
|
||||||
|
<div>{render_slot(item)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a [Heroicon](https://heroicons.com).
|
||||||
|
|
||||||
|
Heroicons come in three styles – outline, solid, and mini.
|
||||||
|
By default, the outline style is used, but solid and mini may
|
||||||
|
be applied by using the `-solid` and `-mini` suffix.
|
||||||
|
|
||||||
|
You can customize the size and colors of the icons by setting
|
||||||
|
width, height, and background color classes.
|
||||||
|
|
||||||
|
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||||
|
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.icon name="hero-x-mark" />
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
"""
|
||||||
|
attr :name, :string, required: true
|
||||||
|
attr :class, :string, default: "size-4"
|
||||||
|
|
||||||
|
def icon(%{name: "hero-" <> _} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<span class={[@name, @class]} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
## JS Commands
|
||||||
|
|
||||||
|
def show(js \\ %JS{}, selector) do
|
||||||
|
JS.show(js,
|
||||||
|
to: selector,
|
||||||
|
time: 300,
|
||||||
|
transition:
|
||||||
|
{"transition-all ease-out duration-300",
|
||||||
|
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||||
|
"opacity-100 translate-y-0 sm:scale-100"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def hide(js \\ %JS{}, selector) do
|
||||||
|
JS.hide(js,
|
||||||
|
to: selector,
|
||||||
|
time: 200,
|
||||||
|
transition:
|
||||||
|
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
|
||||||
|
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates an error message using gettext.
|
||||||
|
"""
|
||||||
|
def translate_error({msg, opts}) do
|
||||||
|
# When using gettext, we typically pass the strings we want
|
||||||
|
# to translate as a static argument:
|
||||||
|
#
|
||||||
|
# # Translate the number of files with plural rules
|
||||||
|
# dngettext("errors", "1 file", "%{count} files", count)
|
||||||
|
#
|
||||||
|
# However the error messages in our forms and APIs are generated
|
||||||
|
# dynamically, so we need to translate them by calling Gettext
|
||||||
|
# with our gettext backend as first argument. Translations are
|
||||||
|
# available in the errors.po file (as we use the "errors" domain).
|
||||||
|
if count = opts[:count] do
|
||||||
|
Gettext.dngettext(MyFirstElixirVibeCodeWeb.Gettext, "errors", msg, msg, count, opts)
|
||||||
|
else
|
||||||
|
Gettext.dgettext(MyFirstElixirVibeCodeWeb.Gettext, "errors", msg, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates the errors for a field from a keyword list of errors.
|
||||||
|
"""
|
||||||
|
def translate_errors(errors, field) when is_list(errors) do
|
||||||
|
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||||
|
end
|
||||||
|
end
|
||||||
154
lib/my_first_elixir_vibe_code_web/components/layouts.ex
Normal file
154
lib/my_first_elixir_vibe_code_web/components/layouts.ex
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.Layouts do
|
||||||
|
@moduledoc """
|
||||||
|
This module holds layouts and related functionality
|
||||||
|
used by your application.
|
||||||
|
"""
|
||||||
|
use MyFirstElixirVibeCodeWeb, :html
|
||||||
|
|
||||||
|
# Embed all files in layouts/* within this module.
|
||||||
|
# The default root.html.heex file contains the HTML
|
||||||
|
# skeleton of your application, namely HTML headers
|
||||||
|
# and other static content.
|
||||||
|
embed_templates "layouts/*"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders your app layout.
|
||||||
|
|
||||||
|
This function is typically invoked from every template,
|
||||||
|
and it often contains your application menu, sidebar,
|
||||||
|
or similar.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<h1>Content</h1>
|
||||||
|
</Layouts.app>
|
||||||
|
|
||||||
|
"""
|
||||||
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||||
|
|
||||||
|
attr :current_scope, :map,
|
||||||
|
default: nil,
|
||||||
|
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||||
|
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def app(assigns) do
|
||||||
|
~H"""
|
||||||
|
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||||
|
<img src={~p"/images/logo.svg"} width="36" />
|
||||||
|
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ul class="flex flex-column px-1 space-x-4 items-center">
|
||||||
|
<li>
|
||||||
|
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.theme_toggle />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
|
||||||
|
Get Started <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||||
|
<div class="mx-auto max-w-2xl space-y-4">
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Shows the flash group with standard titles and content.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
"""
|
||||||
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||||
|
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||||
|
|
||||||
|
def flash_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div id={@id} aria-live="polite">
|
||||||
|
<.flash kind={:info} flash={@flash} />
|
||||||
|
<.flash kind={:error} flash={@flash} />
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="client-error"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("We can't find the internet")}
|
||||||
|
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
||||||
|
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="server-error"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("Something went wrong!")}
|
||||||
|
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
||||||
|
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Provides dark vs light theme toggle based on themes defined in app.css.
|
||||||
|
|
||||||
|
See <head> in root.html.heex which applies the theme before page load.
|
||||||
|
"""
|
||||||
|
def theme_toggle(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||||
|
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex p-2 cursor-pointer w-1/3"
|
||||||
|
phx-click={JS.dispatch("phx:set-theme")}
|
||||||
|
data-phx-theme="system"
|
||||||
|
>
|
||||||
|
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex p-2 cursor-pointer w-1/3"
|
||||||
|
phx-click={JS.dispatch("phx:set-theme")}
|
||||||
|
data-phx-theme="light"
|
||||||
|
>
|
||||||
|
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex p-2 cursor-pointer w-1/3"
|
||||||
|
phx-click={JS.dispatch("phx:set-theme")}
|
||||||
|
data-phx-theme="dark"
|
||||||
|
>
|
||||||
|
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
|
<.live_title default="RateMyClient™" suffix="">
|
||||||
|
{assigns[:page_title]}
|
||||||
|
</.live_title>
|
||||||
|
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||||
|
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (theme === "system") {
|
||||||
|
localStorage.removeItem("phx:theme");
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("phx:theme", theme);
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||||
|
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||||
|
}
|
||||||
|
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||||
|
|
||||||
|
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="navbar bg-base-300 shadow-lg">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<div class="dropdown">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h8m-8 6h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
||||||
|
>
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/reviews/new">Write a Review</a></li>
|
||||||
|
<li><a href="/reviews">Find a Client</a></li>
|
||||||
|
<li><a href="/contact">Contact Us</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-ghost text-xl">RateMyClient™</a>
|
||||||
|
<div class="hidden lg:flex">
|
||||||
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/reviews/new">Write a Review</a></li>
|
||||||
|
<li><a href="/reviews">Find a Client</a></li>
|
||||||
|
<li><a href="/contact">Contact Us</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<%= if assigns[:current_user] do %>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<%= if @current_user.is_admin do %>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-ghost">Admin Dashboard</a>
|
||||||
|
<% end %>
|
||||||
|
<span class="text-sm"><%= @current_user.email || @current_user.username %></span>
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||||
|
<input type="hidden" name="_method" value="delete" />
|
||||||
|
<button type="submit" class="btn btn-error">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/login" class="btn btn-primary">Login</a>
|
||||||
|
<a href="/admin/login" class="btn btn-ghost">Admin</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{@inner_content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
lib/my_first_elixir_vibe_code_web/controllers/error_html.ex
Normal file
24
lib/my_first_elixir_vibe_code_web/controllers/error_html.ex
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ErrorHTML do
|
||||||
|
@moduledoc """
|
||||||
|
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||||
|
|
||||||
|
See config/config.exs.
|
||||||
|
"""
|
||||||
|
use MyFirstElixirVibeCodeWeb, :html
|
||||||
|
|
||||||
|
# If you want to customize your error pages,
|
||||||
|
# uncomment the embed_templates/1 call below
|
||||||
|
# and add pages to the error directory:
|
||||||
|
#
|
||||||
|
# * lib/my_first_elixir_vibe_code_web/controllers/error_html/404.html.heex
|
||||||
|
# * lib/my_first_elixir_vibe_code_web/controllers/error_html/500.html.heex
|
||||||
|
#
|
||||||
|
# embed_templates "error_html/*"
|
||||||
|
|
||||||
|
# The default is to render a plain text page based on
|
||||||
|
# the template name. For example, "404.html" becomes
|
||||||
|
# "Not Found".
|
||||||
|
def render(template, _assigns) do
|
||||||
|
Phoenix.Controller.status_message_from_template(template)
|
||||||
|
end
|
||||||
|
end
|
||||||
21
lib/my_first_elixir_vibe_code_web/controllers/error_json.ex
Normal file
21
lib/my_first_elixir_vibe_code_web/controllers/error_json.ex
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ErrorJSON do
|
||||||
|
@moduledoc """
|
||||||
|
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||||
|
|
||||||
|
See config/config.exs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If you want to customize a particular status code,
|
||||||
|
# you may add your own clauses, such as:
|
||||||
|
#
|
||||||
|
# def render("500.json", _assigns) do
|
||||||
|
# %{errors: %{detail: "Internal Server Error"}}
|
||||||
|
# end
|
||||||
|
|
||||||
|
# By default, Phoenix returns the status message from
|
||||||
|
# the template name. For example, "404.json" becomes
|
||||||
|
# "Not Found".
|
||||||
|
def render(template, _assigns) do
|
||||||
|
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.PageController do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :controller
|
||||||
|
|
||||||
|
def home(conn, _params) do
|
||||||
|
render(conn, :home)
|
||||||
|
end
|
||||||
|
end
|
||||||
10
lib/my_first_elixir_vibe_code_web/controllers/page_html.ex
Normal file
10
lib/my_first_elixir_vibe_code_web/controllers/page_html.ex
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.PageHTML do
|
||||||
|
@moduledoc """
|
||||||
|
This module contains pages rendered by PageController.
|
||||||
|
|
||||||
|
See the `page_html` directory for all templates available.
|
||||||
|
"""
|
||||||
|
use MyFirstElixirVibeCodeWeb, :html
|
||||||
|
|
||||||
|
embed_templates "page_html/*"
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
<Layouts.flash_group flash={@flash} />
|
||||||
|
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-5xl font-bold tracking-tight sm:text-6xl mb-6">
|
||||||
|
RateMyClient
|
||||||
|
</h1>
|
||||||
|
<p class="text-2xl font-semibold text-primary mb-4">
|
||||||
|
Where Contractors Review Clients
|
||||||
|
</p>
|
||||||
|
<p class="text-xl leading-8 text-base-content/80 max-w-2xl mx-auto">
|
||||||
|
Building better business relationships, one honest review at a time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 grid gap-8 md:grid-cols-2">
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">
|
||||||
|
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
For Contractors
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-3 text-base-content/80">
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Screen potential clients</strong> before accepting projects</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Identify red flags early</strong> - late payments, scope creep, unrealistic expectations</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Save time and money</strong> by avoiding problematic clients</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Build your reputation</strong> by leaving professional, constructive reviews</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Connect with quality clients</strong> who value your work</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">
|
||||||
|
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
For Clients
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-3 text-base-content/80">
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Build a strong reputation</strong> as a reliable, professional client</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Attract top contractors</strong> who want to work with you</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Get better bids</strong> - contractors offer better rates to proven clients</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Gain valuable feedback</strong> on how to improve your project management</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="text-primary">✓</span>
|
||||||
|
<span><strong>Stand out from the crowd</strong> with verified positive reviews</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 card bg-primary text-primary-content shadow-xl">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title text-3xl justify-center mb-4">Why Two-Way Reviews Matter</h2>
|
||||||
|
<p class="text-lg leading-7 max-w-3xl mx-auto">
|
||||||
|
Traditional review sites only tell half the story. RateMyClient creates transparency and accountability
|
||||||
|
on both sides of the contractor-client relationship. When both parties know they'll be reviewed,
|
||||||
|
everyone brings their best to the table. The result? Better projects, stronger relationships,
|
||||||
|
and a healthier marketplace for everyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 text-center space-y-4">
|
||||||
|
<div>
|
||||||
|
<a href="/register" class="btn btn-primary btn-lg">
|
||||||
|
Register as Contractor
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-x-4">
|
||||||
|
<a href="/reviews/new" class="btn btn-outline btn-lg">
|
||||||
|
Submit a Review
|
||||||
|
</a>
|
||||||
|
<a href="/reviews" class="btn btn-outline btn-lg">
|
||||||
|
Browse Reviews
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.UserSessionController do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :controller
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts
|
||||||
|
alias MyFirstElixirVibeCodeWeb.UserAuth
|
||||||
|
|
||||||
|
def create(conn, %{"email" => email, "password" => password}) do
|
||||||
|
case Accounts.get_user_by_email_and_password(email, password) do
|
||||||
|
nil ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid email or password")
|
||||||
|
|> redirect(to: ~p"/login")
|
||||||
|
|
||||||
|
user ->
|
||||||
|
UserAuth.log_in_user(conn, user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_admin(conn, %{"username" => username, "password" => password}) do
|
||||||
|
case Accounts.get_user_by_username_and_password(username, password) do
|
||||||
|
nil ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid username or password")
|
||||||
|
|> redirect(to: ~p"/admin/login")
|
||||||
|
|
||||||
|
user ->
|
||||||
|
UserAuth.log_in_user(conn, user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Logged out successfully.")
|
||||||
|
|> UserAuth.log_out_user()
|
||||||
|
end
|
||||||
|
end
|
||||||
54
lib/my_first_elixir_vibe_code_web/endpoint.ex
Normal file
54
lib/my_first_elixir_vibe_code_web/endpoint.ex
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.Endpoint do
|
||||||
|
use Phoenix.Endpoint, otp_app: :my_first_elixir_vibe_code
|
||||||
|
|
||||||
|
# The session will be stored in the cookie and signed,
|
||||||
|
# this means its contents can be read but not tampered with.
|
||||||
|
# Set :encryption_salt if you would also like to encrypt it.
|
||||||
|
@session_options [
|
||||||
|
store: :cookie,
|
||||||
|
key: "_my_first_elixir_vibe_code_key",
|
||||||
|
signing_salt: "6MX1Nc4D",
|
||||||
|
same_site: "Lax"
|
||||||
|
]
|
||||||
|
|
||||||
|
socket "/live", Phoenix.LiveView.Socket,
|
||||||
|
websocket: [connect_info: [session: @session_options]],
|
||||||
|
longpoll: [connect_info: [session: @session_options]]
|
||||||
|
|
||||||
|
# Serve at "/" the static files from "priv/static" directory.
|
||||||
|
#
|
||||||
|
# When code reloading is disabled (e.g., in production),
|
||||||
|
# the `gzip` option is enabled to serve compressed
|
||||||
|
# static files generated by running `phx.digest`.
|
||||||
|
plug Plug.Static,
|
||||||
|
at: "/",
|
||||||
|
from: :my_first_elixir_vibe_code,
|
||||||
|
gzip: not code_reloading?,
|
||||||
|
only: MyFirstElixirVibeCodeWeb.static_paths()
|
||||||
|
|
||||||
|
# Code reloading can be explicitly enabled under the
|
||||||
|
# :code_reloader configuration of your endpoint.
|
||||||
|
if code_reloading? do
|
||||||
|
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||||
|
plug Phoenix.LiveReloader
|
||||||
|
plug Phoenix.CodeReloader
|
||||||
|
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :my_first_elixir_vibe_code
|
||||||
|
end
|
||||||
|
|
||||||
|
plug Phoenix.LiveDashboard.RequestLogger,
|
||||||
|
param_key: "request_logger",
|
||||||
|
cookie_key: "request_logger"
|
||||||
|
|
||||||
|
plug Plug.RequestId
|
||||||
|
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||||
|
|
||||||
|
plug Plug.Parsers,
|
||||||
|
parsers: [:urlencoded, :multipart, :json],
|
||||||
|
pass: ["*/*"],
|
||||||
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
||||||
|
plug Plug.MethodOverride
|
||||||
|
plug Plug.Head
|
||||||
|
plug Plug.Session, @session_options
|
||||||
|
plug MyFirstElixirVibeCodeWeb.Router
|
||||||
|
end
|
||||||
25
lib/my_first_elixir_vibe_code_web/gettext.ex
Normal file
25
lib/my_first_elixir_vibe_code_web/gettext.ex
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.Gettext do
|
||||||
|
@moduledoc """
|
||||||
|
A module providing Internationalization with a gettext-based API.
|
||||||
|
|
||||||
|
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||||
|
that you can use in your application. To use this Gettext backend module,
|
||||||
|
call `use Gettext` and pass it as an option:
|
||||||
|
|
||||||
|
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
|
||||||
|
|
||||||
|
# Simple translation
|
||||||
|
gettext("Here is the string to translate")
|
||||||
|
|
||||||
|
# Plural translation
|
||||||
|
ngettext("Here is the string to translate",
|
||||||
|
"Here are the strings to translate",
|
||||||
|
3)
|
||||||
|
|
||||||
|
# Domain-based translation
|
||||||
|
dgettext("errors", "Here is the error message to translate")
|
||||||
|
|
||||||
|
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||||
|
"""
|
||||||
|
use Gettext.Backend, otp_app: :my_first_elixir_vibe_code
|
||||||
|
end
|
||||||
182
lib/my_first_elixir_vibe_code_web/live/admin_dashboard_live.ex
Normal file
182
lib/my_first_elixir_vibe_code_web/live/admin_dashboard_live.ex
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.AdminDashboardLive do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Reviews
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold">Admin Dashboard</h1>
|
||||||
|
<button phx-click="logout" class="btn btn-error">Logout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs tabs-boxed mb-6">
|
||||||
|
<a
|
||||||
|
class={["tab", @active_tab == "reviews" && "tab-active"]}
|
||||||
|
phx-click="switch_tab"
|
||||||
|
phx-value-tab="reviews"
|
||||||
|
>
|
||||||
|
Reviews (<%= length(@reviews) %>)
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class={["tab", @active_tab == "stats" && "tab-active"]}
|
||||||
|
phx-click="switch_tab"
|
||||||
|
phx-value-tab="stats"
|
||||||
|
>
|
||||||
|
Statistics
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @active_tab == "reviews" do %>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<%= for review <- @reviews do %>
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-primary mb-4">Review Details</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p><strong>Rating:</strong> <%= review.rating %> / 5</p>
|
||||||
|
<p><strong>Title:</strong> <%= review.title %></p>
|
||||||
|
<p><strong>Content:</strong> <%= review.content %></p>
|
||||||
|
<p><strong>Project Type:</strong> <%= review.project_type %></p>
|
||||||
|
<p>
|
||||||
|
<strong>Submitted:</strong>
|
||||||
|
<%= Calendar.strftime(review.inserted_at, "%Y-%m-%d %H:%M") %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-secondary mb-4">
|
||||||
|
Client Information (Private)
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2 bg-base-300 p-4 rounded-lg">
|
||||||
|
<p>
|
||||||
|
<strong>Name:</strong>
|
||||||
|
<%= review.client_first_name %> <%= review.client_last_name %>
|
||||||
|
</p>
|
||||||
|
<p><strong>Street:</strong> <%= review.client_street_address %></p>
|
||||||
|
<p>
|
||||||
|
<strong>City:</strong>
|
||||||
|
<%= review.client_city %>, <%= review.client_state %> <%= review.client_zip %>
|
||||||
|
</p>
|
||||||
|
<%= if review.client_country do %>
|
||||||
|
<p><strong>Country:</strong> <%= review.client_country %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="font-bold mb-2">Verification Documents:</h4>
|
||||||
|
<%= if review.municipal_registration_proof do %>
|
||||||
|
<a
|
||||||
|
href={review.municipal_registration_proof}
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline btn-primary"
|
||||||
|
>
|
||||||
|
View Document
|
||||||
|
</a>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @active_tab == "stats" do %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="stat bg-base-200 rounded-lg shadow-xl">
|
||||||
|
<div class="stat-title">Total Reviews</div>
|
||||||
|
<div class="stat-value text-primary"><%= length(@reviews) %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-200 rounded-lg shadow-xl">
|
||||||
|
<div class="stat-title">Average Rating</div>
|
||||||
|
<div class="stat-value text-secondary"><%= @avg_rating %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-200 rounded-lg shadow-xl">
|
||||||
|
<div class="stat-title">Unique Clients</div>
|
||||||
|
<div class="stat-value text-accent"><%= @unique_clients %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Recent Activity</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
<th>Project Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= for review <- Enum.take(@reviews, 10) do %>
|
||||||
|
<tr>
|
||||||
|
<td><%= Calendar.strftime(review.inserted_at, "%Y-%m-%d") %></td>
|
||||||
|
<td>
|
||||||
|
<%= review.client_first_name %> <%= review.client_last_name %>
|
||||||
|
</td>
|
||||||
|
<td><%= review.rating %> / 5</td>
|
||||||
|
<td><%= review.project_type %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
reviews = Reviews.list_reviews()
|
||||||
|
|
||||||
|
avg_rating =
|
||||||
|
if Enum.empty?(reviews) do
|
||||||
|
0.0
|
||||||
|
else
|
||||||
|
(Enum.sum(Enum.map(reviews, & &1.rating)) / length(reviews))
|
||||||
|
|> Float.round(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
unique_clients =
|
||||||
|
reviews
|
||||||
|
|> Enum.map(&{&1.client_first_name, &1.client_last_name})
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> length()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Admin Dashboard")
|
||||||
|
|> assign(:reviews, reviews)
|
||||||
|
|> assign(:active_tab, "reviews")
|
||||||
|
|> assign(:avg_rating, avg_rating)
|
||||||
|
|> assign(:unique_clients, unique_clients)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
||||||
|
{:noreply, assign(socket, :active_tab, tab)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("logout", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Logged out successfully")
|
||||||
|
|> push_navigate(to: ~p"/login")}
|
||||||
|
end
|
||||||
|
end
|
||||||
53
lib/my_first_elixir_vibe_code_web/live/admin_login_live.ex
Normal file
53
lib/my_first_elixir_vibe_code_web/live/admin_login_live.ex
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.AdminLoginLive do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<div class="flex items-center justify-center min-h-screen">
|
||||||
|
<div class="card w-96 bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title justify-center text-2xl mb-4">Admin Login</h2>
|
||||||
|
<form action={~p"/admin/login"} method="post" class="space-y-4">
|
||||||
|
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Username</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
107
lib/my_first_elixir_vibe_code_web/live/contact_live.ex
Normal file
107
lib/my_first_elixir_vibe_code_web/live/contact_live.ex
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ContactLive do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="card w-full max-w-2xl bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title justify-center text-3xl mb-6">Contact Us</h2>
|
||||||
|
<p class="text-center text-sm opacity-70 mb-4">
|
||||||
|
Have a question or feedback? We'd love to hear from you!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form phx-submit="submit" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Subject</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="subject"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="What is this regarding?"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Message</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
class="textarea textarea-bordered h-32"
|
||||||
|
placeholder="Your message here..."
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
Email us directly at <a href="mailto:support@ratemyclient.com" class="link link-primary">support@ratemyclient.com</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("submit", params, socket) do
|
||||||
|
# This is a non-working contact form
|
||||||
|
# In a real app, you would send an email or save to database here
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Thank you for your message! We'll get back to you soon. (Note: This is a demo form and does not actually send messages)")
|
||||||
|
|> push_navigate(to: ~p"/")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ContractorRegistrationLive do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.Contractor
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
changeset = Accounts.change_contractor_registration(%Contractor{})
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:changeset, changeset)
|
||||||
|
|> assign(:uploaded_files, [])
|
||||||
|
|> allow_upload(:municipal_registration, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)
|
||||||
|
|> allow_upload(:insurance_proof, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"contractor" => contractor_params}, socket) do
|
||||||
|
changeset =
|
||||||
|
%Contractor{}
|
||||||
|
|> Accounts.change_contractor_registration(contractor_params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :changeset, changeset)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("save", %{"contractor" => contractor_params}, socket) do
|
||||||
|
# Upload municipal registration
|
||||||
|
municipal_paths =
|
||||||
|
consume_uploaded_entries(socket, :municipal_registration, fn %{path: path}, entry ->
|
||||||
|
dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}")
|
||||||
|
File.mkdir_p!(Path.dirname(dest))
|
||||||
|
File.cp!(path, dest)
|
||||||
|
{:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Upload insurance proof
|
||||||
|
insurance_paths =
|
||||||
|
consume_uploaded_entries(socket, :insurance_proof, fn %{path: path}, entry ->
|
||||||
|
dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}")
|
||||||
|
File.mkdir_p!(Path.dirname(dest))
|
||||||
|
File.cp!(path, dest)
|
||||||
|
{:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
contractor_params =
|
||||||
|
contractor_params
|
||||||
|
|> Map.put("municipal_registration_proof", List.first(municipal_paths))
|
||||||
|
|> Map.put("insurance_proof", List.first(insurance_paths))
|
||||||
|
|
||||||
|
case Accounts.register_contractor(contractor_params) do
|
||||||
|
{:ok, _contractor} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Registration submitted successfully! Your application is pending review.")
|
||||||
|
|> push_navigate(to: ~p"/")}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, assign(socket, :changeset, changeset)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ext(entry) do
|
||||||
|
[ext | _] = MIME.extensions(entry.client_type)
|
||||||
|
ext
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-2xl px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-4xl font-bold">Contractor Registration</h1>
|
||||||
|
<p class="mt-2 text-lg text-base-content/70">
|
||||||
|
Register to start submitting client reviews
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<.form
|
||||||
|
:let={f}
|
||||||
|
for={@changeset}
|
||||||
|
as={:contractor}
|
||||||
|
phx-change="validate"
|
||||||
|
phx-submit="save"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<.input field={f[:name]} type="text" label="Full Name" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<.input field={f[:email]} type="email" label="Email Address" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<.input field={f[:company_name]} type="text" label="Company Name" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider">Required Documents</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
You must provide proof of at least one municipal registration and current business insurance to complete registration.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">
|
||||||
|
Municipal Registration Proof <span class="text-error">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-control">
|
||||||
|
<label
|
||||||
|
for="municipal_registration"
|
||||||
|
class="btn btn-outline btn-block justify-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Choose File (PDF, JPG, PNG)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="municipal_registration"
|
||||||
|
type="file"
|
||||||
|
phx-hook="Phoenix.LiveFileUpload"
|
||||||
|
data-phx-upload-ref={@uploads.municipal_registration.ref}
|
||||||
|
data-phx-active-refs=""
|
||||||
|
data-phx-done-refs=""
|
||||||
|
data-phx-preflighted-refs=""
|
||||||
|
data-phx-update="ignore"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= for entry <- @uploads.municipal_registration.entries do %>
|
||||||
|
<div class="mt-2 flex items-center gap-2 text-sm">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 text-success"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span><%= entry.client_name %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= for err <- upload_errors(@uploads.municipal_registration) do %>
|
||||||
|
<p class="text-error text-sm mt-1"><%= error_to_string(err) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">
|
||||||
|
Business Insurance Proof <span class="text-error">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-control">
|
||||||
|
<label
|
||||||
|
for="insurance_proof"
|
||||||
|
class="btn btn-outline btn-block justify-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Choose File (PDF, JPG, PNG)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="insurance_proof"
|
||||||
|
type="file"
|
||||||
|
phx-hook="Phoenix.LiveFileUpload"
|
||||||
|
data-phx-upload-ref={@uploads.insurance_proof.ref}
|
||||||
|
data-phx-active-refs=""
|
||||||
|
data-phx-done-refs=""
|
||||||
|
data-phx-preflighted-refs=""
|
||||||
|
data-phx-update="ignore"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= for entry <- @uploads.insurance_proof.entries do %>
|
||||||
|
<div class="mt-2 flex items-center gap-2 text-sm">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 text-success"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span><%= entry.client_name %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= for err <- upload_errors(@uploads.insurance_proof) do %>
|
||||||
|
<p class="text-error text-sm mt-1"><%= error_to_string(err) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-6">
|
||||||
|
<.link navigate={~p"/"} class="btn btn-ghost">Cancel</.link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={
|
||||||
|
!@changeset.valid? || @uploads.municipal_registration.entries == [] ||
|
||||||
|
@uploads.insurance_proof.entries == []
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Submit Registration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error_to_string(:too_large), do: "File is too large"
|
||||||
|
defp error_to_string(:not_accepted), do: "File type not accepted"
|
||||||
|
defp error_to_string(:too_many_files), do: "Too many files"
|
||||||
|
end
|
||||||
258
lib/my_first_elixir_vibe_code_web/live/review_live/form.ex
Normal file
258
lib/my_first_elixir_vibe_code_web/live/review_live/form.ex
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Form do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Reviews
|
||||||
|
alias MyFirstElixirVibeCode.Reviews.Review
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
{@page_title}
|
||||||
|
<:subtitle>Use this form to manage review records in your database.</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.form for={@form} id="review-form" phx-change="validate" phx-submit="save">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Rating</span>
|
||||||
|
</label>
|
||||||
|
<div class="rating rating-lg">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="review[rating]"
|
||||||
|
value="1"
|
||||||
|
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 1}
|
||||||
|
class="mask mask-star-2 bg-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="review[rating]"
|
||||||
|
value="2"
|
||||||
|
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 2}
|
||||||
|
class="mask mask-star-2 bg-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="review[rating]"
|
||||||
|
value="3"
|
||||||
|
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 3}
|
||||||
|
class="mask mask-star-2 bg-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="review[rating]"
|
||||||
|
value="4"
|
||||||
|
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 4}
|
||||||
|
class="mask mask-star-2 bg-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="review[rating]"
|
||||||
|
value="5"
|
||||||
|
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 5}
|
||||||
|
class="mask mask-star-2 bg-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.input field={@form[:title]} type="text" label="Title" />
|
||||||
|
<.input field={@form[:content]} type="textarea" label="Content" />
|
||||||
|
<.input field={@form[:project_type]} type="text" label="Project type" />
|
||||||
|
|
||||||
|
<div class="divider mt-6">Client Information</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
This information is private and will not be shown publicly. It can only be searched by other contractors.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<.input field={@form[:client_first_name]} type="text" label="First Name" required />
|
||||||
|
<.input field={@form[:client_last_name]} type="text" label="Last Name" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={@form[:client_street_address]}
|
||||||
|
type="text"
|
||||||
|
label="Street Address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<.input field={@form[:client_city]} type="text" label="City" required />
|
||||||
|
<.input field={@form[:client_state]} type="text" label="State/Province" required />
|
||||||
|
<.input field={@form[:client_zip]} type="text" label="ZIP/Postal Code" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.input field={@form[:client_country]} type="text" label="Country" />
|
||||||
|
|
||||||
|
<div class="divider mt-6">Required Verification</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
You must provide proof that you are registered in the client's municipality OR that a permit was pulled for this work.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">
|
||||||
|
Verification Document (Municipal Registration OR Work Permit)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-control">
|
||||||
|
<input
|
||||||
|
id="verification_proof_upload"
|
||||||
|
type="file"
|
||||||
|
phx-hook="Phoenix.LiveFileUpload"
|
||||||
|
data-phx-upload-ref={@uploads.verification_proof.ref}
|
||||||
|
data-phx-active-refs=""
|
||||||
|
data-phx-done-refs=""
|
||||||
|
data-phx-preflighted-refs=""
|
||||||
|
data-phx-update="ignore"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
class="file-input file-input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<%= for entry <- @uploads.verification_proof.entries do %>
|
||||||
|
<div class="mt-2 flex items-center gap-2 text-sm text-success">
|
||||||
|
✓ <%= entry.client_name %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-6">
|
||||||
|
<.button phx-disable-with="Saving..." variant="primary">Save Review</.button>
|
||||||
|
<.button navigate={return_path(@return_to, @review)}>Cancel</.button>
|
||||||
|
</footer>
|
||||||
|
</.form>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|
|> allow_upload(:verification_proof, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)
|
||||||
|
|> apply_action(socket.assigns.live_action, params)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp return_to("show"), do: "show"
|
||||||
|
defp return_to(_), do: "index"
|
||||||
|
|
||||||
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||||
|
review = Reviews.get_review!(id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Edit Review")
|
||||||
|
|> assign(:review, review)
|
||||||
|
|> assign(:form, to_form(Reviews.change_review(review)))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_action(socket, :new, _params) do
|
||||||
|
review = %Review{}
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "New Review")
|
||||||
|
|> assign(:review, review)
|
||||||
|
|> assign(:form, to_form(Reviews.change_review(review)))
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"review" => review_params}, socket) do
|
||||||
|
changeset = Reviews.change_review(socket.assigns.review, review_params)
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"review" => review_params}, socket) do
|
||||||
|
save_review(socket, socket.assigns.live_action, review_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_review(socket, :edit, review_params) do
|
||||||
|
review_params = consume_uploads(socket, review_params)
|
||||||
|
|
||||||
|
case Reviews.update_review(socket.assigns.review, review_params) do
|
||||||
|
{:ok, review} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Review updated successfully")
|
||||||
|
|> push_navigate(to: return_path(socket.assigns.return_to, review))}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_review(socket, :new, review_params) do
|
||||||
|
review_params = consume_uploads(socket, review_params)
|
||||||
|
|
||||||
|
case Reviews.create_review(review_params) do
|
||||||
|
{:ok, review} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Review created successfully")
|
||||||
|
|> push_navigate(to: return_path(socket.assigns.return_to, review))}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp consume_uploads(socket, review_params) do
|
||||||
|
# Upload verification proof (either municipal registration or work permit)
|
||||||
|
verification_paths =
|
||||||
|
consume_uploaded_entries(socket, :verification_proof, fn %{path: path}, entry ->
|
||||||
|
dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}")
|
||||||
|
File.mkdir_p!(Path.dirname(dest))
|
||||||
|
File.cp!(path, dest)
|
||||||
|
{:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Store to municipal_registration_proof field
|
||||||
|
review_params
|
||||||
|
|> maybe_put_upload(:municipal_registration_proof, verification_paths)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_upload(params, _key, []), do: params
|
||||||
|
defp maybe_put_upload(params, key, [path | _]), do: Map.put(params, to_string(key), path)
|
||||||
|
|
||||||
|
defp ext(entry) do
|
||||||
|
[ext | _] = MIME.extensions(entry.client_type)
|
||||||
|
ext
|
||||||
|
end
|
||||||
|
|
||||||
|
defp return_path("index", _review), do: ~p"/reviews"
|
||||||
|
defp return_path("show", review), do: ~p"/reviews/#{review}"
|
||||||
|
end
|
||||||
100
lib/my_first_elixir_vibe_code_web/live/review_live/index.ex
Normal file
100
lib/my_first_elixir_vibe_code_web/live/review_live/index.ex
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Index do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Reviews
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
Listing Reviews
|
||||||
|
<:actions>
|
||||||
|
<.button variant="primary" navigate={~p"/reviews/new"}>
|
||||||
|
<.icon name="hero-plus" /> New Review
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<.form for={%{}} phx-change="search" phx-submit="search" class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search_query"
|
||||||
|
value={@search_query}
|
||||||
|
placeholder="Search by client name or address..."
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" phx-click="clear_search" class="btn btn-ghost">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.table
|
||||||
|
id="reviews"
|
||||||
|
rows={@streams.reviews}
|
||||||
|
row_click={fn {_id, review} -> JS.navigate(~p"/reviews/#{review}") end}
|
||||||
|
>
|
||||||
|
<:col :let={{_id, review}} label="Rating">{review.rating}</:col>
|
||||||
|
<:col :let={{_id, review}} label="Title">{review.title}</:col>
|
||||||
|
<:col :let={{_id, review}} label="Content">{review.content}</:col>
|
||||||
|
<:col :let={{_id, review}} label="Project type">{review.project_type}</:col>
|
||||||
|
<:action :let={{_id, review}}>
|
||||||
|
<div class="sr-only">
|
||||||
|
<.link navigate={~p"/reviews/#{review}"}>Show</.link>
|
||||||
|
</div>
|
||||||
|
<.link navigate={~p"/reviews/#{review}/edit"}>Edit</.link>
|
||||||
|
</:action>
|
||||||
|
<:action :let={{id, review}}>
|
||||||
|
<.link
|
||||||
|
phx-click={JS.push("delete", value: %{id: review.id}) |> hide("##{id}")}
|
||||||
|
data-confirm="Are you sure?"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</.link>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Listing Reviews")
|
||||||
|
|> assign(:search_query, "")
|
||||||
|
|> stream(:reviews, list_reviews(""))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("search", %{"search_query" => query}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:search_query, query)
|
||||||
|
|> stream(:reviews, list_reviews(query), reset: true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("clear_search", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:search_query, "")
|
||||||
|
|> stream(:reviews, list_reviews(""), reset: true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
review = Reviews.get_review!(id)
|
||||||
|
{:ok, _} = Reviews.delete_review(review)
|
||||||
|
|
||||||
|
{:noreply, stream_delete(socket, :reviews, review)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_reviews(search_query) do
|
||||||
|
Reviews.search_reviews(search_query)
|
||||||
|
end
|
||||||
|
end
|
||||||
40
lib/my_first_elixir_vibe_code_web/live/review_live/show.ex
Normal file
40
lib/my_first_elixir_vibe_code_web/live/review_live/show.ex
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Show do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Reviews
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
Review {@review.id}
|
||||||
|
<:subtitle>This is a review record from your database.</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button navigate={~p"/reviews"}>
|
||||||
|
<.icon name="hero-arrow-left" />
|
||||||
|
</.button>
|
||||||
|
<.button variant="primary" navigate={~p"/reviews/#{@review}/edit?return_to=show"}>
|
||||||
|
<.icon name="hero-pencil-square" /> Edit review
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.list>
|
||||||
|
<:item title="Rating">{@review.rating}</:item>
|
||||||
|
<:item title="Title">{@review.title}</:item>
|
||||||
|
<:item title="Content">{@review.content}</:item>
|
||||||
|
<:item title="Project type">{@review.project_type}</:item>
|
||||||
|
</.list>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Show Review")
|
||||||
|
|> assign(:review, Reviews.get_review!(id))}
|
||||||
|
end
|
||||||
|
end
|
||||||
62
lib/my_first_elixir_vibe_code_web/live/user_login_live.ex
Normal file
62
lib/my_first_elixir_vibe_code_web/live/user_login_live.ex
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.UserLoginLive do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<div class="flex items-center justify-center min-h-screen">
|
||||||
|
<div class="card w-96 bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title justify-center text-2xl mb-4">Login</h2>
|
||||||
|
<form action={~p"/login"} method="post" class="space-y-4">
|
||||||
|
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="/register" class="link link-primary">Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
105
lib/my_first_elixir_vibe_code_web/live/user_registration_live.ex
Normal file
105
lib/my_first_elixir_vibe_code_web/live/user_registration_live.ex
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.UserRegistrationLive do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :live_view
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.User
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<div class="flex items-center justify-center min-h-screen">
|
||||||
|
<div class="card w-96 bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title justify-center text-2xl mb-4">Register</h2>
|
||||||
|
<.form
|
||||||
|
for={@form}
|
||||||
|
id="registration_form"
|
||||||
|
phx-submit="save"
|
||||||
|
phx-change="validate"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<.input field={@form[:email]} type="email" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
</label>
|
||||||
|
<.input field={@form[:password]} type="password" required />
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Must be at least 6 characters</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm">
|
||||||
|
Already have an account?
|
||||||
|
<a href="/login" class="link link-primary">Log in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
changeset = Accounts.change_user(%User{})
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(trigger_submit: false)
|
||||||
|
|> assign_form(changeset)
|
||||||
|
|
||||||
|
{:ok, socket, temporary_assigns: [form: nil]}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||||
|
changeset =
|
||||||
|
%User{}
|
||||||
|
|> Accounts.change_user(user_params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign_form(socket, changeset)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"user" => user_params}, socket) do
|
||||||
|
case Accounts.create_user(user_params) do
|
||||||
|
{:ok, user} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "User created successfully")
|
||||||
|
|> push_navigate(to: ~p"/login")}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||||
|
form = to_form(changeset, as: "user")
|
||||||
|
|
||||||
|
if changeset.valid? do
|
||||||
|
assign(socket, form: form, check_errors: false)
|
||||||
|
else
|
||||||
|
assign(socket, form: form)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
98
lib/my_first_elixir_vibe_code_web/router.ex
Normal file
98
lib/my_first_elixir_vibe_code_web/router.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.Router do
|
||||||
|
use MyFirstElixirVibeCodeWeb, :router
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCodeWeb.UserAuth
|
||||||
|
|
||||||
|
pipeline :browser do
|
||||||
|
plug :accepts, ["html"]
|
||||||
|
plug :fetch_session
|
||||||
|
plug :fetch_live_flash
|
||||||
|
plug :put_root_layout, html: {MyFirstElixirVibeCodeWeb.Layouts, :root}
|
||||||
|
plug :protect_from_forgery
|
||||||
|
plug :put_secure_browser_headers
|
||||||
|
plug UserAuth, :fetch_current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :api do
|
||||||
|
plug :accepts, ["json"]
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :guest_only do
|
||||||
|
plug UserAuth, :redirect_if_user_is_authenticated
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :auth_required do
|
||||||
|
plug UserAuth, :require_authenticated_user
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :admin_only do
|
||||||
|
plug UserAuth, :require_admin_user
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", MyFirstElixirVibeCodeWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
get "/", PageController, :home
|
||||||
|
|
||||||
|
live "/contractor/register", ContractorRegistrationLive
|
||||||
|
live "/contact", ContactLive
|
||||||
|
|
||||||
|
live "/reviews", ReviewLive.Index, :index
|
||||||
|
live "/reviews/new", ReviewLive.Form, :new
|
||||||
|
live "/reviews/:id", ReviewLive.Show, :show
|
||||||
|
live "/reviews/:id/edit", ReviewLive.Form, :edit
|
||||||
|
end
|
||||||
|
|
||||||
|
## Authentication routes
|
||||||
|
scope "/", MyFirstElixirVibeCodeWeb do
|
||||||
|
pipe_through [:browser, :guest_only]
|
||||||
|
|
||||||
|
live_session :redirect_if_user_is_authenticated,
|
||||||
|
on_mount: [{MyFirstElixirVibeCodeWeb.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||||
|
live "/register", UserRegistrationLive, :new
|
||||||
|
live "/login", UserLoginLive, :new
|
||||||
|
live "/admin/login", AdminLoginLive, :new
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/login", UserSessionController, :create
|
||||||
|
post "/admin/login", UserSessionController, :create_admin
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", MyFirstElixirVibeCodeWeb do
|
||||||
|
pipe_through [:browser, :auth_required]
|
||||||
|
|
||||||
|
delete "/logout", UserSessionController, :delete
|
||||||
|
end
|
||||||
|
|
||||||
|
## Admin routes
|
||||||
|
scope "/admin", MyFirstElixirVibeCodeWeb do
|
||||||
|
pipe_through [:browser, :auth_required, :admin_only]
|
||||||
|
|
||||||
|
live_session :require_admin_user,
|
||||||
|
on_mount: [{MyFirstElixirVibeCodeWeb.UserAuth, :ensure_authenticated}] do
|
||||||
|
live "/dashboard", AdminDashboardLive
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Other scopes may use custom stacks.
|
||||||
|
# scope "/api", MyFirstElixirVibeCodeWeb do
|
||||||
|
# pipe_through :api
|
||||||
|
# end
|
||||||
|
|
||||||
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
|
if Application.compile_env(:my_first_elixir_vibe_code, :dev_routes) do
|
||||||
|
# If you want to use the LiveDashboard in production, you should put
|
||||||
|
# it behind authentication and allow only admins to access it.
|
||||||
|
# If your application does not have an admins-only section yet,
|
||||||
|
# you can use Plug.BasicAuth to set up some basic authentication
|
||||||
|
# as long as you are also using SSL (which you should anyway).
|
||||||
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
|
scope "/dev" do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
live_dashboard "/dashboard", metrics: MyFirstElixirVibeCodeWeb.Telemetry
|
||||||
|
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
93
lib/my_first_elixir_vibe_code_web/telemetry.ex
Normal file
93
lib/my_first_elixir_vibe_code_web/telemetry.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.Telemetry do
|
||||||
|
use Supervisor
|
||||||
|
import Telemetry.Metrics
|
||||||
|
|
||||||
|
def start_link(arg) do
|
||||||
|
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_arg) do
|
||||||
|
children = [
|
||||||
|
# Telemetry poller will execute the given period measurements
|
||||||
|
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||||
|
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||||
|
# Add reporters as children of your supervision tree.
|
||||||
|
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||||
|
]
|
||||||
|
|
||||||
|
Supervisor.init(children, strategy: :one_for_one)
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics do
|
||||||
|
[
|
||||||
|
# Phoenix Metrics
|
||||||
|
summary("phoenix.endpoint.start.system_time",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.endpoint.stop.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.start.system_time",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.exception.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.stop.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.socket_connected.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
sum("phoenix.socket_drain.count"),
|
||||||
|
summary("phoenix.channel_joined.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.channel_handled_in.duration",
|
||||||
|
tags: [:event],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
|
||||||
|
# Database Metrics
|
||||||
|
summary("my_first_elixir_vibe_code.repo.query.total_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The sum of the other measurements"
|
||||||
|
),
|
||||||
|
summary("my_first_elixir_vibe_code.repo.query.decode_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent decoding the data received from the database"
|
||||||
|
),
|
||||||
|
summary("my_first_elixir_vibe_code.repo.query.query_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent executing the query"
|
||||||
|
),
|
||||||
|
summary("my_first_elixir_vibe_code.repo.query.queue_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent waiting for a database connection"
|
||||||
|
),
|
||||||
|
summary("my_first_elixir_vibe_code.repo.query.idle_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description:
|
||||||
|
"The time the connection spent waiting before being checked out for the query"
|
||||||
|
),
|
||||||
|
|
||||||
|
# VM Metrics
|
||||||
|
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||||
|
summary("vm.total_run_queue_lengths.total"),
|
||||||
|
summary("vm.total_run_queue_lengths.cpu"),
|
||||||
|
summary("vm.total_run_queue_lengths.io")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp periodic_measurements do
|
||||||
|
[
|
||||||
|
# A module, function and arguments to be invoked periodically.
|
||||||
|
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||||
|
# {MyFirstElixirVibeCodeWeb, :count_users, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
173
lib/my_first_elixir_vibe_code_web/user_auth.ex
Normal file
173
lib/my_first_elixir_vibe_code_web/user_auth.ex
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.UserAuth do
|
||||||
|
@moduledoc """
|
||||||
|
User authentication module for managing user sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use MyFirstElixirVibeCodeWeb, :verified_routes
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts
|
||||||
|
|
||||||
|
@max_age 60 * 60 * 24 * 60 # 60 days
|
||||||
|
@remember_me_cookie "_my_first_elixir_vibe_code_web_user_remember_me"
|
||||||
|
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
||||||
|
|
||||||
|
# Plug behaviour
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, opts) when is_atom(opts) do
|
||||||
|
apply(__MODULE__, opts, [conn, opts])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs the user in.
|
||||||
|
|
||||||
|
It renews the session ID to avoid fixation attacks.
|
||||||
|
"""
|
||||||
|
def log_in_user(conn, user) do
|
||||||
|
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||||
|
user_return_to = get_session(conn, :user_return_to)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> renew_session()
|
||||||
|
|> put_token_in_session(token)
|
||||||
|
|> put_session(:user_id, user.id)
|
||||||
|
|> put_session(:is_admin, user.is_admin)
|
||||||
|
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||||
|
|> redirect(to: user_return_to || signed_in_path(user))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp signed_in_path(user) do
|
||||||
|
if user.is_admin do
|
||||||
|
~p"/admin/dashboard"
|
||||||
|
else
|
||||||
|
~p"/"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp renew_session(conn) do
|
||||||
|
conn
|
||||||
|
|> configure_session(renew: true)
|
||||||
|
|> clear_session()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs the user out.
|
||||||
|
|
||||||
|
It clears all session data for safety.
|
||||||
|
"""
|
||||||
|
def log_out_user(conn) do
|
||||||
|
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||||
|
MyFirstElixirVibeCodeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> renew_session()
|
||||||
|
|> delete_resp_cookie(@remember_me_cookie)
|
||||||
|
|> redirect(to: ~p"/")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Authenticates the user by looking into the session.
|
||||||
|
"""
|
||||||
|
def fetch_current_user(conn, _opts) do
|
||||||
|
user_id = get_session(conn, :user_id)
|
||||||
|
user = user_id && Accounts.get_user!(user_id)
|
||||||
|
assign(conn, :current_user, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Handles mounting and authenticating the current_user in LiveViews.
|
||||||
|
"""
|
||||||
|
def on_mount(:mount_current_user, _params, session, socket) do
|
||||||
|
{:cont, mount_current_user(socket, session)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:ensure_authenticated, _params, session, socket) do
|
||||||
|
socket = mount_current_user(socket, session)
|
||||||
|
|
||||||
|
if socket.assigns.current_user do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||||
|
|> Phoenix.LiveView.redirect(to: ~p"/login")
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
|
||||||
|
socket = mount_current_user(socket, session)
|
||||||
|
|
||||||
|
if socket.assigns.current_user do
|
||||||
|
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.current_user))}
|
||||||
|
else
|
||||||
|
{:cont, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mount_current_user(socket, session) do
|
||||||
|
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
||||||
|
if user_id = session["user_id"] do
|
||||||
|
Accounts.get_user!(user_id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Used for routes that require the user to not be authenticated.
|
||||||
|
"""
|
||||||
|
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||||
|
if conn.assigns[:current_user] do
|
||||||
|
conn
|
||||||
|
|> redirect(to: signed_in_path(conn.assigns.current_user))
|
||||||
|
|> halt()
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Used for routes that require the user to be authenticated.
|
||||||
|
"""
|
||||||
|
def require_authenticated_user(conn, _opts) do
|
||||||
|
if conn.assigns[:current_user] do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "You must log in to access this page.")
|
||||||
|
|> maybe_store_return_to()
|
||||||
|
|> redirect(to: ~p"/login")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Used for routes that require the user to be an admin.
|
||||||
|
"""
|
||||||
|
def require_admin_user(conn, _opts) do
|
||||||
|
if conn.assigns[:current_user] && conn.assigns.current_user.is_admin do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "You must be an admin to access this page.")
|
||||||
|
|> redirect(to: ~p"/")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_token_in_session(conn, token) do
|
||||||
|
conn
|
||||||
|
|> put_session(:user_token, token)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||||
|
put_session(conn, :user_return_to, current_path(conn))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store_return_to(conn), do: conn
|
||||||
|
end
|
||||||
95
mix.exs
Normal file
95
mix.exs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :my_first_elixir_vibe_code,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.15",
|
||||||
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
aliases: aliases(),
|
||||||
|
deps: deps(),
|
||||||
|
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||||
|
listeners: [Phoenix.CodeReloader]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration for the OTP application.
|
||||||
|
#
|
||||||
|
# Type `mix help compile.app` for more information.
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
mod: {MyFirstElixirVibeCode.Application, []},
|
||||||
|
extra_applications: [:logger, :runtime_tools]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def cli do
|
||||||
|
[
|
||||||
|
preferred_envs: [precommit: :test]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Specifies which paths to compile per environment.
|
||||||
|
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||||
|
defp elixirc_paths(_), do: ["lib"]
|
||||||
|
|
||||||
|
# Specifies your project dependencies.
|
||||||
|
#
|
||||||
|
# Type `mix help deps` for examples and options.
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:phoenix, "~> 1.8.1"},
|
||||||
|
{:phoenix_ecto, "~> 4.5"},
|
||||||
|
{:ecto_sql, "~> 3.13"},
|
||||||
|
{:postgrex, ">= 0.0.0"},
|
||||||
|
{:phoenix_html, "~> 4.1"},
|
||||||
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
|
{:phoenix_live_view, "~> 1.1.0"},
|
||||||
|
{:lazy_html, ">= 0.1.0", only: :test},
|
||||||
|
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||||
|
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||||
|
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
||||||
|
{:heroicons,
|
||||||
|
github: "tailwindlabs/heroicons",
|
||||||
|
tag: "v2.2.0",
|
||||||
|
sparse: "optimized",
|
||||||
|
app: false,
|
||||||
|
compile: false,
|
||||||
|
depth: 1},
|
||||||
|
{:swoosh, "~> 1.16"},
|
||||||
|
{:req, "~> 0.5"},
|
||||||
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
|
{:telemetry_poller, "~> 1.0"},
|
||||||
|
{:gettext, "~> 0.26"},
|
||||||
|
{:jason, "~> 1.2"},
|
||||||
|
{:dns_cluster, "~> 0.2.0"},
|
||||||
|
{:bandit, "~> 1.5"},
|
||||||
|
{:bcrypt_elixir, "~> 3.0"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Aliases are shortcuts or tasks specific to the current project.
|
||||||
|
# For example, to install project dependencies and perform other setup tasks, run:
|
||||||
|
#
|
||||||
|
# $ mix setup
|
||||||
|
#
|
||||||
|
# See the documentation for `Mix` for more info on aliases.
|
||||||
|
defp aliases do
|
||||||
|
[
|
||||||
|
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||||
|
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||||
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
|
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||||
|
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||||
|
"assets.build": ["compile", "tailwind my_first_elixir_vibe_code", "esbuild my_first_elixir_vibe_code"],
|
||||||
|
"assets.deploy": [
|
||||||
|
"tailwind my_first_elixir_vibe_code --minify",
|
||||||
|
"esbuild my_first_elixir_vibe_code --minify",
|
||||||
|
"phx.digest"
|
||||||
|
],
|
||||||
|
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
48
mix.lock
Normal file
48
mix.lock
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
%{
|
||||||
|
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||||
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||||
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
|
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||||
|
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||||
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
|
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||||
|
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
|
||||||
|
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||||
|
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||||
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
|
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||||
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
|
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||||
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
|
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||||
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
|
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||||
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
|
"phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"},
|
||||||
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||||
|
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||||
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
|
||||||
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
|
||||||
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
|
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
|
||||||
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
|
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||||
|
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
|
||||||
|
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
|
||||||
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
|
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||||
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
|
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||||
|
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
||||||
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
}
|
||||||
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
## `msgid`s in this file come from POT (.pot) files.
|
||||||
|
##
|
||||||
|
## Do not add, change, or remove `msgid`s manually here as
|
||||||
|
## they're tied to the ones in the corresponding POT file
|
||||||
|
## (with the same domain).
|
||||||
|
##
|
||||||
|
## Use `mix gettext.extract --merge` or `mix gettext.merge`
|
||||||
|
## to merge POT files into PO files.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Language: en\n"
|
||||||
|
|
||||||
|
## From Ecto.Changeset.cast/4
|
||||||
|
msgid "can't be blank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.unique_constraint/3
|
||||||
|
msgid "has already been taken"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.put_change/3
|
||||||
|
msgid "is invalid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_acceptance/3
|
||||||
|
msgid "must be accepted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_format/3
|
||||||
|
msgid "has invalid format"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_subset/3
|
||||||
|
msgid "has an invalid entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_exclusion/3
|
||||||
|
msgid "is reserved"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_confirmation/3
|
||||||
|
msgid "does not match confirmation"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.no_assoc_constraint/3
|
||||||
|
msgid "is still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "are still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_length/3
|
||||||
|
msgid "should have %{count} item(s)"
|
||||||
|
msgid_plural "should have %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} character(s)"
|
||||||
|
msgid_plural "should be %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} byte(s)"
|
||||||
|
msgid_plural "should be %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at least %{count} item(s)"
|
||||||
|
msgid_plural "should have at least %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} character(s)"
|
||||||
|
msgid_plural "should be at least %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} byte(s)"
|
||||||
|
msgid_plural "should be at least %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at most %{count} item(s)"
|
||||||
|
msgid_plural "should have at most %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} character(s)"
|
||||||
|
msgid_plural "should be at most %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} byte(s)"
|
||||||
|
msgid_plural "should be at most %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_number/3
|
||||||
|
msgid "must be less than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be less than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
109
priv/gettext/errors.pot
Normal file
109
priv/gettext/errors.pot
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
## This is a PO Template file.
|
||||||
|
##
|
||||||
|
## `msgid`s here are often extracted from source code.
|
||||||
|
## Add new translations manually only if they're dynamic
|
||||||
|
## translations that can't be statically extracted.
|
||||||
|
##
|
||||||
|
## Run `mix gettext.extract` to bring this file up to
|
||||||
|
## date. Leave `msgstr`s empty as changing them here has no
|
||||||
|
## effect: edit them in PO (`.po`) files instead.
|
||||||
|
## From Ecto.Changeset.cast/4
|
||||||
|
msgid "can't be blank"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.unique_constraint/3
|
||||||
|
msgid "has already been taken"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.put_change/3
|
||||||
|
msgid "is invalid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_acceptance/3
|
||||||
|
msgid "must be accepted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_format/3
|
||||||
|
msgid "has invalid format"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_subset/3
|
||||||
|
msgid "has an invalid entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_exclusion/3
|
||||||
|
msgid "is reserved"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_confirmation/3
|
||||||
|
msgid "does not match confirmation"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.no_assoc_constraint/3
|
||||||
|
msgid "is still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "are still associated with this entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_length/3
|
||||||
|
msgid "should have %{count} item(s)"
|
||||||
|
msgid_plural "should have %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} character(s)"
|
||||||
|
msgid_plural "should be %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be %{count} byte(s)"
|
||||||
|
msgid_plural "should be %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at least %{count} item(s)"
|
||||||
|
msgid_plural "should have at least %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} character(s)"
|
||||||
|
msgid_plural "should be at least %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at least %{count} byte(s)"
|
||||||
|
msgid_plural "should be at least %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should have at most %{count} item(s)"
|
||||||
|
msgid_plural "should have at most %{count} item(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} character(s)"
|
||||||
|
msgid_plural "should be at most %{count} character(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "should be at most %{count} byte(s)"
|
||||||
|
msgid_plural "should be at most %{count} byte(s)"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
## From Ecto.Changeset.validate_number/3
|
||||||
|
msgid "must be less than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be less than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be greater than or equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "must be equal to %{number}"
|
||||||
|
msgstr ""
|
||||||
4
priv/repo/migrations/.formatter.exs
Normal file
4
priv/repo/migrations/.formatter.exs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[
|
||||||
|
import_deps: [:ecto_sql],
|
||||||
|
inputs: ["*.exs"]
|
||||||
|
]
|
||||||
13
priv/repo/migrations/20251128193059_create_clients.exs
Normal file
13
priv/repo/migrations/20251128193059_create_clients.exs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateClients do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:clients) do
|
||||||
|
add :name, :string
|
||||||
|
add :email, :string
|
||||||
|
add :company_name, :string
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
13
priv/repo/migrations/20251128193112_create_contractors.exs
Normal file
13
priv/repo/migrations/20251128193112_create_contractors.exs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateContractors do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:contractors) do
|
||||||
|
add :name, :string
|
||||||
|
add :email, :string
|
||||||
|
add :company_name, :string
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
19
priv/repo/migrations/20251128193117_create_reviews.exs
Normal file
19
priv/repo/migrations/20251128193117_create_reviews.exs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateReviews do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:reviews) do
|
||||||
|
add :rating, :integer
|
||||||
|
add :title, :string
|
||||||
|
add :content, :text
|
||||||
|
add :project_type, :string
|
||||||
|
add :contractor_id, references(:contractors, on_delete: :nothing)
|
||||||
|
add :client_id, references(:clients, on_delete: :nothing)
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:reviews, [:contractor_id])
|
||||||
|
create index(:reviews, [:client_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddDocumentsToContractors do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:contractors) do
|
||||||
|
add :municipal_registration_proof, :string
|
||||||
|
add :insurance_proof, :string
|
||||||
|
add :registration_status, :string, default: "pending"
|
||||||
|
add :verified_at, :utc_datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddUniqueIndexToContractorEmail do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create unique_index(:contractors, [:email])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddDocumentsToReviews do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:reviews) do
|
||||||
|
add :municipal_registration_proof, :string
|
||||||
|
add :work_permit_proof, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddClientInfoToReviews do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:reviews) do
|
||||||
|
add :client_name, :string
|
||||||
|
add :client_address, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:reviews, [:client_name])
|
||||||
|
create index(:reviews, [:client_address])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.UpdateClientFieldsInReviews do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:reviews) do
|
||||||
|
# Remove old fields
|
||||||
|
remove :client_name
|
||||||
|
remove :client_address
|
||||||
|
|
||||||
|
# Add split name fields
|
||||||
|
add :client_first_name, :string
|
||||||
|
add :client_last_name, :string
|
||||||
|
|
||||||
|
# Add address components
|
||||||
|
add :client_street_address, :string
|
||||||
|
add :client_city, :string
|
||||||
|
add :client_state, :string
|
||||||
|
add :client_zip, :string
|
||||||
|
add :client_country, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create indexes for searching
|
||||||
|
create index(:reviews, [:client_first_name])
|
||||||
|
create index(:reviews, [:client_last_name])
|
||||||
|
create index(:reviews, [:client_street_address])
|
||||||
|
create index(:reviews, [:client_city])
|
||||||
|
end
|
||||||
|
end
|
||||||
15
priv/repo/migrations/20251128213538_create_users.exs
Normal file
15
priv/repo/migrations/20251128213538_create_users.exs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateUsers do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:users) do
|
||||||
|
add :email, :string, null: false
|
||||||
|
add :password_hash, :string, null: false
|
||||||
|
add :is_admin, :boolean, default: false, null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:users, [:email])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddUsernameToUsers do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:users) do
|
||||||
|
add :username, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:users, [:username])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.Repo.Migrations.MakeEmailNullable do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:users) do
|
||||||
|
modify :email, :string, null: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
11
priv/repo/seeds.exs
Normal file
11
priv/repo/seeds.exs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Script for populating the database. You can run it as:
|
||||||
|
#
|
||||||
|
# mix run priv/repo/seeds.exs
|
||||||
|
#
|
||||||
|
# Inside the script, you can read and write to any of your
|
||||||
|
# repositories directly:
|
||||||
|
#
|
||||||
|
# MyFirstElixirVibeCode.Repo.insert!(%MyFirstElixirVibeCode.SomeSchema{})
|
||||||
|
#
|
||||||
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
|
# and so on) as they will fail if something goes wrong.
|
||||||
BIN
priv/static/favicon.ico
Normal file
BIN
priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
6
priv/static/images/logo.svg
Normal file
6
priv/static/images/logo.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
|
||||||
|
fill="#FD4F00"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3 KiB |
5
priv/static/robots.txt
Normal file
5
priv/static/robots.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||||
|
#
|
||||||
|
# To ban all spiders from the entire site uncomment the next two lines:
|
||||||
|
# User-agent: *
|
||||||
|
# Disallow: /
|
||||||
121
test/my_first_elixir_vibe_code/accounts_test.exs
Normal file
121
test/my_first_elixir_vibe_code/accounts_test.exs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.AccountsTest do
|
||||||
|
use MyFirstElixirVibeCode.DataCase
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Accounts
|
||||||
|
|
||||||
|
describe "clients" do
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.Client
|
||||||
|
|
||||||
|
import MyFirstElixirVibeCode.AccountsFixtures
|
||||||
|
|
||||||
|
@invalid_attrs %{name: nil, email: nil, company_name: nil}
|
||||||
|
|
||||||
|
test "list_clients/0 returns all clients" do
|
||||||
|
client = client_fixture()
|
||||||
|
assert Accounts.list_clients() == [client]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_client!/1 returns the client with given id" do
|
||||||
|
client = client_fixture()
|
||||||
|
assert Accounts.get_client!(client.id) == client
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_client/1 with valid data creates a client" do
|
||||||
|
valid_attrs = %{name: "some name", email: "some email", company_name: "some company_name"}
|
||||||
|
|
||||||
|
assert {:ok, %Client{} = client} = Accounts.create_client(valid_attrs)
|
||||||
|
assert client.name == "some name"
|
||||||
|
assert client.email == "some email"
|
||||||
|
assert client.company_name == "some company_name"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_client/1 with invalid data returns error changeset" do
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Accounts.create_client(@invalid_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_client/2 with valid data updates the client" do
|
||||||
|
client = client_fixture()
|
||||||
|
update_attrs = %{name: "some updated name", email: "some updated email", company_name: "some updated company_name"}
|
||||||
|
|
||||||
|
assert {:ok, %Client{} = client} = Accounts.update_client(client, update_attrs)
|
||||||
|
assert client.name == "some updated name"
|
||||||
|
assert client.email == "some updated email"
|
||||||
|
assert client.company_name == "some updated company_name"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_client/2 with invalid data returns error changeset" do
|
||||||
|
client = client_fixture()
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Accounts.update_client(client, @invalid_attrs)
|
||||||
|
assert client == Accounts.get_client!(client.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_client/1 deletes the client" do
|
||||||
|
client = client_fixture()
|
||||||
|
assert {:ok, %Client{}} = Accounts.delete_client(client)
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Accounts.get_client!(client.id) end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_client/1 returns a client changeset" do
|
||||||
|
client = client_fixture()
|
||||||
|
assert %Ecto.Changeset{} = Accounts.change_client(client)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "contractors" do
|
||||||
|
alias MyFirstElixirVibeCode.Accounts.Contractor
|
||||||
|
|
||||||
|
import MyFirstElixirVibeCode.AccountsFixtures
|
||||||
|
|
||||||
|
@invalid_attrs %{name: nil, email: nil, company_name: nil}
|
||||||
|
|
||||||
|
test "list_contractors/0 returns all contractors" do
|
||||||
|
contractor = contractor_fixture()
|
||||||
|
assert Accounts.list_contractors() == [contractor]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_contractor!/1 returns the contractor with given id" do
|
||||||
|
contractor = contractor_fixture()
|
||||||
|
assert Accounts.get_contractor!(contractor.id) == contractor
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_contractor/1 with valid data creates a contractor" do
|
||||||
|
valid_attrs = %{name: "some name", email: "some email", company_name: "some company_name"}
|
||||||
|
|
||||||
|
assert {:ok, %Contractor{} = contractor} = Accounts.create_contractor(valid_attrs)
|
||||||
|
assert contractor.name == "some name"
|
||||||
|
assert contractor.email == "some email"
|
||||||
|
assert contractor.company_name == "some company_name"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_contractor/1 with invalid data returns error changeset" do
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Accounts.create_contractor(@invalid_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_contractor/2 with valid data updates the contractor" do
|
||||||
|
contractor = contractor_fixture()
|
||||||
|
update_attrs = %{name: "some updated name", email: "some updated email", company_name: "some updated company_name"}
|
||||||
|
|
||||||
|
assert {:ok, %Contractor{} = contractor} = Accounts.update_contractor(contractor, update_attrs)
|
||||||
|
assert contractor.name == "some updated name"
|
||||||
|
assert contractor.email == "some updated email"
|
||||||
|
assert contractor.company_name == "some updated company_name"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_contractor/2 with invalid data returns error changeset" do
|
||||||
|
contractor = contractor_fixture()
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Accounts.update_contractor(contractor, @invalid_attrs)
|
||||||
|
assert contractor == Accounts.get_contractor!(contractor.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_contractor/1 deletes the contractor" do
|
||||||
|
contractor = contractor_fixture()
|
||||||
|
assert {:ok, %Contractor{}} = Accounts.delete_contractor(contractor)
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Accounts.get_contractor!(contractor.id) end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_contractor/1 returns a contractor changeset" do
|
||||||
|
contractor = contractor_fixture()
|
||||||
|
assert %Ecto.Changeset{} = Accounts.change_contractor(contractor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
65
test/my_first_elixir_vibe_code/reviews_test.exs
Normal file
65
test/my_first_elixir_vibe_code/reviews_test.exs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.ReviewsTest do
|
||||||
|
use MyFirstElixirVibeCode.DataCase
|
||||||
|
|
||||||
|
alias MyFirstElixirVibeCode.Reviews
|
||||||
|
|
||||||
|
describe "reviews" do
|
||||||
|
alias MyFirstElixirVibeCode.Reviews.Review
|
||||||
|
|
||||||
|
import MyFirstElixirVibeCode.ReviewsFixtures
|
||||||
|
|
||||||
|
@invalid_attrs %{title: nil, rating: nil, content: nil, project_type: nil}
|
||||||
|
|
||||||
|
test "list_reviews/0 returns all reviews" do
|
||||||
|
review = review_fixture()
|
||||||
|
assert Reviews.list_reviews() == [review]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_review!/1 returns the review with given id" do
|
||||||
|
review = review_fixture()
|
||||||
|
assert Reviews.get_review!(review.id) == review
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_review/1 with valid data creates a review" do
|
||||||
|
valid_attrs = %{title: "some title", rating: 42, content: "some content", project_type: "some project_type"}
|
||||||
|
|
||||||
|
assert {:ok, %Review{} = review} = Reviews.create_review(valid_attrs)
|
||||||
|
assert review.title == "some title"
|
||||||
|
assert review.rating == 42
|
||||||
|
assert review.content == "some content"
|
||||||
|
assert review.project_type == "some project_type"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_review/1 with invalid data returns error changeset" do
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Reviews.create_review(@invalid_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_review/2 with valid data updates the review" do
|
||||||
|
review = review_fixture()
|
||||||
|
update_attrs = %{title: "some updated title", rating: 43, content: "some updated content", project_type: "some updated project_type"}
|
||||||
|
|
||||||
|
assert {:ok, %Review{} = review} = Reviews.update_review(review, update_attrs)
|
||||||
|
assert review.title == "some updated title"
|
||||||
|
assert review.rating == 43
|
||||||
|
assert review.content == "some updated content"
|
||||||
|
assert review.project_type == "some updated project_type"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_review/2 with invalid data returns error changeset" do
|
||||||
|
review = review_fixture()
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Reviews.update_review(review, @invalid_attrs)
|
||||||
|
assert review == Reviews.get_review!(review.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_review/1 deletes the review" do
|
||||||
|
review = review_fixture()
|
||||||
|
assert {:ok, %Review{}} = Reviews.delete_review(review)
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Reviews.get_review!(review.id) end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "change_review/1 returns a review changeset" do
|
||||||
|
review = review_fixture()
|
||||||
|
assert %Ecto.Changeset{} = Reviews.change_review(review)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ErrorHTMLTest do
|
||||||
|
use MyFirstElixirVibeCodeWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
# Bring render_to_string/4 for testing custom views
|
||||||
|
import Phoenix.Template, only: [render_to_string: 4]
|
||||||
|
|
||||||
|
test "renders 404.html" do
|
||||||
|
assert render_to_string(MyFirstElixirVibeCodeWeb.ErrorHTML, "404", "html", []) == "Not Found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500.html" do
|
||||||
|
assert render_to_string(MyFirstElixirVibeCodeWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ErrorJSONTest do
|
||||||
|
use MyFirstElixirVibeCodeWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
test "renders 404" do
|
||||||
|
assert MyFirstElixirVibeCodeWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500" do
|
||||||
|
assert MyFirstElixirVibeCodeWeb.ErrorJSON.render("500.json", %{}) ==
|
||||||
|
%{errors: %{detail: "Internal Server Error"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.PageControllerTest do
|
||||||
|
use MyFirstElixirVibeCodeWeb.ConnCase
|
||||||
|
|
||||||
|
test "GET /", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/")
|
||||||
|
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
||||||
|
end
|
||||||
|
end
|
||||||
122
test/my_first_elixir_vibe_code_web/live/review_live_test.exs
Normal file
122
test/my_first_elixir_vibe_code_web/live/review_live_test.exs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ReviewLiveTest do
|
||||||
|
use MyFirstElixirVibeCodeWeb.ConnCase
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import MyFirstElixirVibeCode.ReviewsFixtures
|
||||||
|
|
||||||
|
@create_attrs %{title: "some title", rating: 42, content: "some content", project_type: "some project_type"}
|
||||||
|
@update_attrs %{title: "some updated title", rating: 43, content: "some updated content", project_type: "some updated project_type"}
|
||||||
|
@invalid_attrs %{title: nil, rating: nil, content: nil, project_type: nil}
|
||||||
|
defp create_review(_) do
|
||||||
|
review = review_fixture()
|
||||||
|
|
||||||
|
%{review: review}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Index" do
|
||||||
|
setup [:create_review]
|
||||||
|
|
||||||
|
test "lists all reviews", %{conn: conn, review: review} do
|
||||||
|
{:ok, _index_live, html} = live(conn, ~p"/reviews")
|
||||||
|
|
||||||
|
assert html =~ "Listing Reviews"
|
||||||
|
assert html =~ review.title
|
||||||
|
end
|
||||||
|
|
||||||
|
test "saves new review", %{conn: conn} do
|
||||||
|
{:ok, index_live, _html} = live(conn, ~p"/reviews")
|
||||||
|
|
||||||
|
assert {:ok, form_live, _} =
|
||||||
|
index_live
|
||||||
|
|> element("a", "New Review")
|
||||||
|
|> render_click()
|
||||||
|
|> follow_redirect(conn, ~p"/reviews/new")
|
||||||
|
|
||||||
|
assert render(form_live) =~ "New Review"
|
||||||
|
|
||||||
|
assert form_live
|
||||||
|
|> form("#review-form", review: @invalid_attrs)
|
||||||
|
|> render_change() =~ "can't be blank"
|
||||||
|
|
||||||
|
assert {:ok, index_live, _html} =
|
||||||
|
form_live
|
||||||
|
|> form("#review-form", review: @create_attrs)
|
||||||
|
|> render_submit()
|
||||||
|
|> follow_redirect(conn, ~p"/reviews")
|
||||||
|
|
||||||
|
html = render(index_live)
|
||||||
|
assert html =~ "Review created successfully"
|
||||||
|
assert html =~ "some title"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates review in listing", %{conn: conn, review: review} do
|
||||||
|
{:ok, index_live, _html} = live(conn, ~p"/reviews")
|
||||||
|
|
||||||
|
assert {:ok, form_live, _html} =
|
||||||
|
index_live
|
||||||
|
|> element("#reviews-#{review.id} a", "Edit")
|
||||||
|
|> render_click()
|
||||||
|
|> follow_redirect(conn, ~p"/reviews/#{review}/edit")
|
||||||
|
|
||||||
|
assert render(form_live) =~ "Edit Review"
|
||||||
|
|
||||||
|
assert form_live
|
||||||
|
|> form("#review-form", review: @invalid_attrs)
|
||||||
|
|> render_change() =~ "can't be blank"
|
||||||
|
|
||||||
|
assert {:ok, index_live, _html} =
|
||||||
|
form_live
|
||||||
|
|> form("#review-form", review: @update_attrs)
|
||||||
|
|> render_submit()
|
||||||
|
|> follow_redirect(conn, ~p"/reviews")
|
||||||
|
|
||||||
|
html = render(index_live)
|
||||||
|
assert html =~ "Review updated successfully"
|
||||||
|
assert html =~ "some updated title"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes review in listing", %{conn: conn, review: review} do
|
||||||
|
{:ok, index_live, _html} = live(conn, ~p"/reviews")
|
||||||
|
|
||||||
|
assert index_live |> element("#reviews-#{review.id} a", "Delete") |> render_click()
|
||||||
|
refute has_element?(index_live, "#reviews-#{review.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Show" do
|
||||||
|
setup [:create_review]
|
||||||
|
|
||||||
|
test "displays review", %{conn: conn, review: review} do
|
||||||
|
{:ok, _show_live, html} = live(conn, ~p"/reviews/#{review}")
|
||||||
|
|
||||||
|
assert html =~ "Show Review"
|
||||||
|
assert html =~ review.title
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates review and returns to show", %{conn: conn, review: review} do
|
||||||
|
{:ok, show_live, _html} = live(conn, ~p"/reviews/#{review}")
|
||||||
|
|
||||||
|
assert {:ok, form_live, _} =
|
||||||
|
show_live
|
||||||
|
|> element("a", "Edit")
|
||||||
|
|> render_click()
|
||||||
|
|> follow_redirect(conn, ~p"/reviews/#{review}/edit?return_to=show")
|
||||||
|
|
||||||
|
assert render(form_live) =~ "Edit Review"
|
||||||
|
|
||||||
|
assert form_live
|
||||||
|
|> form("#review-form", review: @invalid_attrs)
|
||||||
|
|> render_change() =~ "can't be blank"
|
||||||
|
|
||||||
|
assert {:ok, show_live, _html} =
|
||||||
|
form_live
|
||||||
|
|> form("#review-form", review: @update_attrs)
|
||||||
|
|> render_submit()
|
||||||
|
|> follow_redirect(conn, ~p"/reviews/#{review}")
|
||||||
|
|
||||||
|
html = render(show_live)
|
||||||
|
assert html =~ "Review updated successfully"
|
||||||
|
assert html =~ "some updated title"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
38
test/support/conn_case.ex
Normal file
38
test/support/conn_case.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule MyFirstElixirVibeCodeWeb.ConnCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the test case to be used by
|
||||||
|
tests that require setting up a connection.
|
||||||
|
|
||||||
|
Such tests rely on `Phoenix.ConnTest` and also
|
||||||
|
import other functionality to make it easier
|
||||||
|
to build common data structures and query the data layer.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use MyFirstElixirVibeCodeWeb.ConnCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
# The default endpoint for testing
|
||||||
|
@endpoint MyFirstElixirVibeCodeWeb.Endpoint
|
||||||
|
|
||||||
|
use MyFirstElixirVibeCodeWeb, :verified_routes
|
||||||
|
|
||||||
|
# Import conveniences for testing with connections
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import MyFirstElixirVibeCodeWeb.ConnCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
MyFirstElixirVibeCode.DataCase.setup_sandbox(tags)
|
||||||
|
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||||
|
end
|
||||||
|
end
|
||||||
58
test/support/data_case.ex
Normal file
58
test/support/data_case.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.DataCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the setup for tests requiring
|
||||||
|
access to the application's data layer.
|
||||||
|
|
||||||
|
You may define functions here to be used as helpers in
|
||||||
|
your tests.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use MyFirstElixirVibeCode.DataCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
alias MyFirstElixirVibeCode.Repo
|
||||||
|
|
||||||
|
import Ecto
|
||||||
|
import Ecto.Changeset
|
||||||
|
import Ecto.Query
|
||||||
|
import MyFirstElixirVibeCode.DataCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
MyFirstElixirVibeCode.DataCase.setup_sandbox(tags)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets up the sandbox based on the test tags.
|
||||||
|
"""
|
||||||
|
def setup_sandbox(tags) do
|
||||||
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyFirstElixirVibeCode.Repo, shared: not tags[:async])
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A helper that transforms changeset errors into a map of messages.
|
||||||
|
|
||||||
|
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||||
|
assert "password is too short" in errors_on(changeset).password
|
||||||
|
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def errors_on(changeset) do
|
||||||
|
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||||
|
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||||
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
38
test/support/fixtures/accounts_fixtures.ex
Normal file
38
test/support/fixtures/accounts_fixtures.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.AccountsFixtures do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines test helpers for creating
|
||||||
|
entities via the `MyFirstElixirVibeCode.Accounts` context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a client.
|
||||||
|
"""
|
||||||
|
def client_fixture(attrs \\ %{}) do
|
||||||
|
{:ok, client} =
|
||||||
|
attrs
|
||||||
|
|> Enum.into(%{
|
||||||
|
company_name: "some company_name",
|
||||||
|
email: "some email",
|
||||||
|
name: "some name"
|
||||||
|
})
|
||||||
|
|> MyFirstElixirVibeCode.Accounts.create_client()
|
||||||
|
|
||||||
|
client
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a contractor.
|
||||||
|
"""
|
||||||
|
def contractor_fixture(attrs \\ %{}) do
|
||||||
|
{:ok, contractor} =
|
||||||
|
attrs
|
||||||
|
|> Enum.into(%{
|
||||||
|
company_name: "some company_name",
|
||||||
|
email: "some email",
|
||||||
|
name: "some name"
|
||||||
|
})
|
||||||
|
|> MyFirstElixirVibeCode.Accounts.create_contractor()
|
||||||
|
|
||||||
|
contractor
|
||||||
|
end
|
||||||
|
end
|
||||||
23
test/support/fixtures/reviews_fixtures.ex
Normal file
23
test/support/fixtures/reviews_fixtures.ex
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
defmodule MyFirstElixirVibeCode.ReviewsFixtures do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines test helpers for creating
|
||||||
|
entities via the `MyFirstElixirVibeCode.Reviews` context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a review.
|
||||||
|
"""
|
||||||
|
def review_fixture(attrs \\ %{}) do
|
||||||
|
{:ok, review} =
|
||||||
|
attrs
|
||||||
|
|> Enum.into(%{
|
||||||
|
content: "some content",
|
||||||
|
project_type: "some project_type",
|
||||||
|
rating: 42,
|
||||||
|
title: "some title"
|
||||||
|
})
|
||||||
|
|> MyFirstElixirVibeCode.Reviews.create_review()
|
||||||
|
|
||||||
|
review
|
||||||
|
end
|
||||||
|
end
|
||||||
2
test/test_helper.exs
Normal file
2
test/test_helper.exs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ExUnit.start()
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(MyFirstElixirVibeCode.Repo, :manual)
|
||||||
Loading…
Reference in a new issue