diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..5d8610d5d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## Description +_Provide a detailed description of the purpose of the changes included in this pull request. Optionally, include background information, relevant screenshots, and any other context that helps explain the work._ + +## Related Issues +_If applicable, specify the main parts of the application that will be impacted by this pull request._ + +## Steps to reproduce or test +_Describe the steps that you did to reproduce this._ \ No newline at end of file diff --git a/assets/css/components/avatar.css b/assets/css/components/avatar.css index 1bdae7f01..8772c6a81 100644 --- a/assets/css/components/avatar.css +++ b/assets/css/components/avatar.css @@ -21,23 +21,23 @@ /* Avatar - sizes */ .atomic-avatar--xs { - @apply h-8 w-8 text-xs; + @apply size-8 text-xs; } .atomic-avatar--sm { - @apply h-12 w-12 text-lg; + @apply size-12 text-lg; } .atomic-avatar--md { - @apply h-16 w-16 text-lg; + @apply size-16 text-lg; } .atomic-avatar--lg { - @apply h-20 w-20 text-4xl; + @apply size-20 text-4xl; } .atomic-avatar--xl { - @apply h-24 w-24 text-4xl; + @apply size-24 text-4xl; } /* Avatar - colors */ @@ -94,4 +94,10 @@ .atomic-avatar--src { @apply bg-transparent; +} + +/* Avatar Group */ + +.atomic-avatar-grouped { + @apply ring-1 ring-white; } \ No newline at end of file diff --git a/assets/css/components/button.css b/assets/css/components/button.css index afe6bc3ab..73abda247 100644 --- a/assets/css/components/button.css +++ b/assets/css/components/button.css @@ -159,19 +159,19 @@ } .atomic-button__icon--xs { - @apply w-3 h-3; + @apply !size-3 } .atomic-button__icon--sm { - @apply w-4 h-4; + @apply !size-4 } .atomic-button__icon--md { - @apply w-5 h-5; + @apply !size-5 } .atomic-button__icon--lg { - @apply w-5 h-5; + @apply !size-5 } .atomic-button__icon--xl { - @apply w-6 h-6; + @apply !size-6 } /* Buttons - with full width */ @@ -185,96 +185,6 @@ @apply flex items-center gap-2 whitespace-nowrap; } -/* Icon Button */ - -.atomic-icon-button { - @apply inline-block p-2 rounded-full; -} - -/* Icon Buttons - colors */ - -.atomic-icon-button--primary { - @apply text-primary-600 dark:text-primary-500; -} -.atomic-icon-button--secondary { - @apply text-secondary-600 dark:text-secondary-500; -} -.atomic-icon-button--gray { - @apply text-gray-600 dark:text-gray-500; -} -.atomic-icon-button--info { - @apply text-info-600 dark:text-info-500; -} -.atomic-icon-button--success { - @apply text-success-600 dark:text-success-500; -} -.atomic-icon-button--warning { - @apply text-warning-600 dark:text-warning-500; -} -.atomic-icon-button--danger { - @apply text-danger-600 dark:text-danger-500; -} - -/* Icon Buttons - background colors */ - -.atomic-icon-button-bg--primary { - @apply hover:bg-primary-50 dark:hover:bg-gray-800; -} -.atomic-icon-button-bg--secondary { - @apply hover:bg-secondary-50 dark:hover:bg-gray-800; -} -.atomic-icon-button-bg--gray { - @apply hover:bg-gray-100 dark:hover:bg-gray-800; -} -.atomic-icon-button-bg--info { - @apply hover:bg-info-50 dark:hover:bg-gray-800; -} -.atomic-icon-button-bg--success { - @apply hover:bg-success-50 dark:hover:bg-gray-800; -} -.atomic-icon-button-bg--warning { - @apply hover:bg-warning-50 dark:hover:bg-gray-800; -} -.atomic-icon-button-bg--danger { - @apply hover:bg-danger-50 dark:hover:bg-gray-800; -} - -/* Icon Button - sizes */ - -.atomic-icon-button--xs { - @apply w-9 h-9; -} -.atomic-icon-button--sm { - @apply w-10 h-10; -} -.atomic-icon-button--md { - @apply w-11 h-11; -} -.atomic-icon-button--lg { - @apply w-12 h-12; -} -.atomic-icon-button--xl { - @apply w-14 h-14; -} - -/* Icon Button - spinner */ - -.atomic-icon-button-spinner--xs { - @apply w-5 h-5; -} -.atomic-icon-button-spinner--sm { - @apply w-6 h-6; -} -.atomic-icon-button-spinner--md { - @apply w-7 h-7; -} -.atomic-icon-button-spinner--lg { - @apply w-8 h-8; -} -.atomic-icon-button-spinner--xl { - @apply w-10 h-10; -} - /* Button - disabled */ .atomic-button--disabled { diff --git a/assets/js/app.js b/assets/js/app.js index 8c30db156..afd7e3fa9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,28 +24,15 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import "../vendor/alpine.js"; import topbar from "../vendor/topbar" -import { QrScanner } from "./qr_reading.js"; -import { InitSorting } from "./sorting.js"; -import { StickyScroll } from "./sticky_scroll.js"; +import { QrScanner, InitSorting, StickyScroll, ScrollToTop } from "./hooks"; let Hooks = { QrScanner: QrScanner, InitSorting: InitSorting, - StickyScroll: StickyScroll + StickyScroll: StickyScroll, + ScrollToTop: ScrollToTop }; -Hooks.ScrollToTop = { - mounted() { - this.el.addEventListener("click", e => { - e.preventDefault() - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); - }) - } -} - let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js new file mode 100644 index 000000000..14c200a5f --- /dev/null +++ b/assets/js/hooks/index.js @@ -0,0 +1,4 @@ +export { QrScanner } from "./qr_reading.js"; +export { InitSorting } from "./sorting.js"; +export { StickyScroll } from "./sticky_scroll.js"; +export { ScrollToTop } from "./scroll_to_top.js"; \ No newline at end of file diff --git a/assets/js/qr_reading.js b/assets/js/hooks/qr_reading.js similarity index 94% rename from assets/js/qr_reading.js rename to assets/js/hooks/qr_reading.js index bea603b67..a52685d7a 100644 --- a/assets/js/qr_reading.js +++ b/assets/js/hooks/qr_reading.js @@ -1,4 +1,4 @@ -import { Html5Qrcode, Html5QrcodeSupportedFormats } from "../vendor/html5-qrcode.js" +import { Html5Qrcode, Html5QrcodeSupportedFormats } from "../../vendor/html5-qrcode.js" function parseURL(url) { try { diff --git a/assets/js/hooks/scroll_to_top.js b/assets/js/hooks/scroll_to_top.js new file mode 100644 index 000000000..2fa29b844 --- /dev/null +++ b/assets/js/hooks/scroll_to_top.js @@ -0,0 +1,11 @@ +export const ScrollToTop = { + mounted() { + this.el.addEventListener("click", e => { + e.preventDefault() + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }) + } +} \ No newline at end of file diff --git a/assets/js/sorting.js b/assets/js/hooks/sorting.js similarity index 88% rename from assets/js/sorting.js rename to assets/js/hooks/sorting.js index 4cd391e0f..d0d79d999 100644 --- a/assets/js/sorting.js +++ b/assets/js/hooks/sorting.js @@ -1,4 +1,4 @@ -import Sortable from "../vendor/sortable.js" +import Sortable from "../../vendor/sortable.js" export const InitSorting = { mounted() { diff --git a/assets/js/sticky_scroll.js b/assets/js/hooks/sticky_scroll.js similarity index 80% rename from assets/js/sticky_scroll.js rename to assets/js/hooks/sticky_scroll.js index 03b96e284..bc7282ab0 100644 --- a/assets/js/sticky_scroll.js +++ b/assets/js/hooks/sticky_scroll.js @@ -2,6 +2,7 @@ export const StickyScroll = { mounted() { window.addEventListener("scroll",() => { const panel = document.getElementById("scroll-panel"); + if(panel == null) { window.removeEventListener("scroll", this); return; } if(window.innerHeight > panel.offsetHeight) return; panel.style.top = -Math.min(Math.max(window.scrollY, 0), panel.offsetHeight - window.innerHeight) + "px"; }); diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index a89824018..201fc0ee5 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -1,6 +1,9 @@ const colors = require("tailwindcss/colors"); const plugin = require('tailwindcss/plugin'); +const fs = require("fs"); +const path = require("path"); + module.exports = { darkMode: 'selector', content: [ @@ -25,23 +28,23 @@ module.exports = { }, keyframes: { wave: { - '0%': { transform: 'rotate(0.0deg)' }, - '15%': { transform: 'rotate(14.0deg)' }, - '30%': { transform: 'rotate(-8.0deg)' }, - '40%': { transform: 'rotate(14.0deg)' }, - '50%': { transform: 'rotate(-4.0deg)' }, - '60%': { transform: 'rotate(10.0deg)' }, - '70%': { transform: 'rotate(0.0deg)' }, - '100%': { transform: 'rotate(0.0deg)' }, + '0%': { transform: 'rotate(0.0deg)' }, + '15%': { transform: 'rotate(14.0deg)' }, + '30%': { transform: 'rotate(-8.0deg)' }, + '40%': { transform: 'rotate(14.0deg)' }, + '50%': { transform: 'rotate(-4.0deg)' }, + '60%': { transform: 'rotate(10.0deg)' }, + '70%': { transform: 'rotate(0.0deg)' }, + '100%': { transform: 'rotate(0.0deg)' }, }, progress: { - '0%': { width: '0%' }, - '100%': { width: '100%' }, + '0%': { width: '0%' }, + '100%': { width: '100%' }, } }, animation: { - wave: 'wave 1.5s infinite', - progress: 'progress 5s linear 1' + wave: 'wave 1.5s infinite', + progress: 'progress 5s linear 1' }, backgroundSize: { '75': '75%' @@ -50,9 +53,92 @@ module.exports = { }, plugins: [ require('@tailwindcss/forms'), + + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), - plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) + plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])), + + // Embeds Heroicons (https://heroicons.com) into app.css bundle + 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, "") + 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 }) + }), + + // Embeds Tabler icons (https://tablericons.com) into app.css bundle + plugin(function ({ matchComponents, theme }) { + let iconsDir = path.join(__dirname, "../deps/tabler_icons/icons") + let values = {} + let icons = [ + ["", "/outline"], + ["-filled", "/filled"], + ] + 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({ + "tabler": ({ name, fullPath }) => { + let content = fs.readFileSync(fullPath).toString() + .replace(/\r?\n|\r/g, "") + .replace(/width="[^"]*"/, "") + .replace(/height="[^"]*"/, ""); + + return { + [`--tabler-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--tabler-${name})`, + "mask": `var(--tabler-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": theme("spacing.5"), + "height": theme("spacing.5") + } + } + }, { values }) + }) ] } diff --git a/config/dev.exs b/config/dev.exs index c808516f5..74b18ae40 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -61,7 +61,7 @@ config :atomic, AtomicWeb.Endpoint, patterns: [ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", - ~r"lib/atomic_web/(live|views)/.*(ex)$", + ~r"lib/atomic_web/(live|views|components)/.*(ex)$", ~r"lib/atomic_web/templates/.*(eex)$", ~r"storybook/.*(exs)$" ] diff --git a/lib/atomic/accounts.ex b/lib/atomic/accounts.ex index 97b67564c..676aa304d 100644 --- a/lib/atomic/accounts.ex +++ b/lib/atomic/accounts.ex @@ -252,7 +252,7 @@ defmodule Atomic.Accounts do |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) end - @doc """ + @doc ~S""" Delivers the update email instructions to the given user. ## Examples @@ -260,7 +260,7 @@ defmodule Atomic.Accounts do iex> deliver_update_email_instructions( ...> user, ...> current_email, - ...> &Routes.user_update_email_url(conn, :edit, &1) + ...> &url(~p"/users/settings/confirm_email/#{&1}") ...> ) {:ok, %{to: ..., body: ...}} @@ -348,21 +348,15 @@ defmodule Atomic.Accounts do ## Confirmation - @doc """ + @doc ~S""" Delivers the confirmation email instructions to the given user. ## Examples - iex> deliver_user_confirmation_instructions( - ...> user, - ...> &Routes.user_confirmation_url(conn, :edit, &1) - ...> ) + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) {:ok, %{to: ..., body: ...}} - iex> deliver_user_confirmation_instructions( - ...> confirmed_user, - ...> &Routes.user_confirmation_url(conn, :edit, &1) - ...> ) + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) {:error, :already_confirmed} """ @@ -401,15 +395,12 @@ defmodule Atomic.Accounts do ## Reset password - @doc """ + @doc ~S""" Delivers the reset password email to the given user. ## Examples - iex> deliver_user_reset_password_instructions( - ...> user, - ...> &Routes.user_reset_password_url(conn, :edit, &1) - ...> ) + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) {:ok, %{to: ..., body: ...}} """ diff --git a/lib/atomic/schema.ex b/lib/atomic/schema.ex index 068dc3aa6..9f890413a 100644 --- a/lib/atomic/schema.ex +++ b/lib/atomic/schema.ex @@ -2,16 +2,16 @@ defmodule Atomic.Schema do @moduledoc """ The application Schema for all the modules, providing Ecto.UUIDs as default id. """ - alias Atomic.Time + use Gettext, backend: AtomicWeb.Gettext - import AtomicWeb.Gettext + alias Atomic.Time defmacro __using__(_) do quote do use Ecto.Schema use Waffle.Ecto.Schema + use Gettext, backend: AtomicWeb.Gettext - import AtomicWeb.Gettext import Ecto.Changeset import Ecto.Query diff --git a/lib/atomic/uploader.ex b/lib/atomic/uploader.ex index 6e0f8d049..8bb59c9fe 100644 --- a/lib/atomic/uploader.ex +++ b/lib/atomic/uploader.ex @@ -4,37 +4,23 @@ defmodule Atomic.Uploader do Put `use Atomic.Uploader` on top of your uploader module to use it. """ - @versions [:original, :medium, :thumb] - @extensions_whitelist ~w(.svg .jpg .jpeg .png) - - defmacro __using__(_) do + defmacro __using__(opts) do quote do use Waffle.Definition use Waffle.Ecto.Definition def validate({file, _}) do - file.file_name - |> Path.extname() - |> String.downcase() - |> then(&Enum.member?(Atomic.Uploader.extensions_whitelist(), &1)) - |> case do + file_extension = file.file_name |> Path.extname() |> String.downcase() + + case Enum.member?(extension_whitelist(), file_extension) do true -> :ok - false -> {:error, "invalid file type"} + false -> {:error, "invalid file extension"} end end - def transform(:thumb, _) do - {:convert, "-strip -thumbnail 100x150^ -gravity center -extent 100x150 -format png", :png} - end - - def transform(:medium, _) do - {:convert, "-strip -thumbnail 400x600^ -gravity center -extent 400x600 -format png", :png} + def extension_whitelist do + Keyword.get(unquote(opts), :extensions, []) end - - def filename(version, _), do: version end end - - def versions, do: @versions - def extensions_whitelist, do: @extensions_whitelist end diff --git a/lib/atomic/uploaders/banner.ex b/lib/atomic/uploaders/banner.ex index b642d1eb1..665f2d291 100644 --- a/lib/atomic/uploaders/banner.ex +++ b/lib/atomic/uploaders/banner.ex @@ -2,11 +2,17 @@ defmodule Atomic.Uploaders.Banner do @moduledoc """ Uploader for department banners. """ - use Atomic.Uploader + use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png) alias Atomic.Organizations.Department - def storage_dir(_version, {_file, %Department{} = scope}) do - "uploads/atomic/departments/#{scope.id}/banner" + @versions [:original] + + def storage_dir(_version, {_file, %Department{} = department}) do + "uploads/atomic/departments/#{department.id}/banner" + end + + def filename(version, _) do + version end end diff --git a/lib/atomic/uploaders/logo.ex b/lib/atomic/uploaders/logo.ex index 91f29f1a6..2757694ba 100644 --- a/lib/atomic/uploaders/logo.ex +++ b/lib/atomic/uploaders/logo.ex @@ -2,10 +2,16 @@ defmodule Atomic.Uploaders.Logo do @moduledoc """ Uploader for organization logos. """ - use Atomic.Uploader + use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .svg) alias Atomic.Organizations.Organization - def storage_dir(_version, {_file, %Organization{} = scope}) do - "uploads/atomic/logos/#{scope.id}" + @versions [:original] + + def storage_dir(_version, {_file, %Organization{} = organization}) do + "uploads/atomic/organizations/#{organization.id}/logo" + end + + def filename(version, _) do + version end end diff --git a/lib/atomic/uploaders/partner_image.ex b/lib/atomic/uploaders/partner_image.ex index 1cb6c345a..0bf84f0b3 100644 --- a/lib/atomic/uploaders/partner_image.ex +++ b/lib/atomic/uploaders/partner_image.ex @@ -2,11 +2,17 @@ defmodule Atomic.Uploaders.PartnerImage do @moduledoc """ Uploader for partner images. """ - use Atomic.Uploader + use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png) alias Atomic.Organizations.Partner - def storage_dir(_version, {_file, %Partner{} = scope}) do - "uploads/atomic/partners/#{scope.id}" + @versions [:original] + + def storage_dir(_version, {_file, %Partner{} = partner}) do + "uploads/atomic/partners/#{partner.id}/logo" + end + + def filename(version, _) do + version end end diff --git a/lib/atomic/uploaders/post.ex b/lib/atomic/uploaders/post.ex index 94233430f..e3557799a 100644 --- a/lib/atomic/uploaders/post.ex +++ b/lib/atomic/uploaders/post.ex @@ -2,16 +2,22 @@ defmodule Atomic.Uploaders.Post do @moduledoc """ Uploader for posts. """ - use Atomic.Uploader + use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .svg) alias Atomic.Activities.Activity alias Atomic.Organizations.Announcement - def storage_dir(_version, {_file, %Activity{} = scope}) do - "uploads/atomic/activities/#{scope.id}" + @versions [:original] + + def storage_dir(_version, {_file, %Activity{} = activity}) do + "uploads/atomic/activities/#{activity.id}/image" + end + + def storage_dir(_version, {_file, %Announcement{} = announcement}) do + "uploads/atomic/announcements/#{announcement.id}/image" end - def storage_dir(_version, {_file, %Announcement{} = scope}) do - "uploads/atomic/announcements/#{scope.id}" + def filename(version, _) do + version end end diff --git a/lib/atomic/uploaders/profile_picture.ex b/lib/atomic/uploaders/profile_picture.ex index 0ab557ee9..06a93519b 100644 --- a/lib/atomic/uploaders/profile_picture.ex +++ b/lib/atomic/uploaders/profile_picture.ex @@ -2,10 +2,16 @@ defmodule Atomic.Uploaders.ProfilePicture do @moduledoc """ Uploader for profile pictures. """ - use Atomic.Uploader + use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .gif) alias Atomic.Accounts.User - def storage_dir(_version, {_file, %User{} = scope}) do - "uploads/atomic/profile_pictures/#{scope.id}" + @versions [:original] + + def storage_dir(_version, {_file, %User{} = user}) do + "uploads/atomic/users/#{user.id}/profile_picture" + end + + def filename(version, _) do + version end end diff --git a/lib/atomic_web.ex b/lib/atomic_web.ex index 53f6c7367..7581894af 100644 --- a/lib/atomic_web.ex +++ b/lib/atomic_web.ex @@ -17,13 +17,16 @@ defmodule AtomicWeb do and import those modules here. """ + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + def controller do quote do use Phoenix.Controller, namespace: AtomicWeb + use Gettext, backend: AtomicWeb.Gettext import Plug.Conn - import AtomicWeb.Gettext - alias AtomicWeb.Router.Helpers, as: Routes + + unquote(verified_routes()) end end @@ -80,7 +83,16 @@ defmodule AtomicWeb do def channel do quote do use Phoenix.Channel - import AtomicWeb.Gettext + use Gettext, backend: AtomicWeb.Gettext + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: AtomicWeb.Endpoint, + router: AtomicWeb.Router, + statics: AtomicWeb.static_paths() end end @@ -89,22 +101,23 @@ defmodule AtomicWeb do # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML - # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + # Import LiveView and .heex helpers (<.link>, <.form>, etc) import Phoenix.LiveView.Helpers import Phoenix.Component - # Import commonly used components - unquote(components()) - # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View + # Custom uses, imports and aliases + unquote(components()) + + use AtomicWeb, :verified_routes + use Gettext, backend: AtomicWeb.Gettext + import AtomicWeb.ErrorHelpers - import AtomicWeb.Gettext import AtomicWeb.Helpers alias Atomic.Uploaders - alias AtomicWeb.Router.Helpers, as: Routes end end diff --git a/lib/atomic_web/components/activity.ex b/lib/atomic_web/components/activity.ex index 84ee53e61..aed7ba501 100644 --- a/lib/atomic_web/components/activity.ex +++ b/lib/atomic_web/components/activity.ex @@ -19,7 +19,7 @@ defmodule AtomicWeb.Components.Activity do
- <.link navigate={Routes.organization_show_path(AtomicWeb.Endpoint, :show, @activity.organization.id)}> + <.link navigate={~p"/organizations/#{@activity.organization.id}"}> <%= @activity.organization.name %> @@ -33,12 +33,12 @@ defmodule AtomicWeb.Components.Activity do

<%= @activity.title %>

-

<%= @activity.description %>

+

<%= maybe_slice_string(@activity.description, 300) %>

<%= if @activity.image do %>
- +
<% end %> @@ -46,21 +46,21 @@ defmodule AtomicWeb.Components.Activity do
- <.icon name={:clock} solid class="h-5 w-5" /> - <%= relative_datetime(@activity.start) %> + <.icon name="hero-calendar-solid" class="mr-1.5 h-5 w-5 flex-shrink-0 text-zinc-400" /> + <%= pretty_display_date(@activity.start) %> starting in - <.icon name={:user_group} solid class="h-5 w-5" /> + <.icon name="hero-user-group-solid" class="size-5" /> <%= @activity.enrolled %>/<%= @activity.maximum_entries %> - enrollments + enrollments - <.icon name={:map_pin} solid class="h-5 w-5" /> + <.icon name="hero-map-pin-solid" class="size-5" /> <%= @activity.location.name %> location diff --git a/lib/atomic_web/components/announcement.ex b/lib/atomic_web/components/announcement.ex index 78569fe90..5913ea8a0 100644 --- a/lib/atomic_web/components/announcement.ex +++ b/lib/atomic_web/components/announcement.ex @@ -17,7 +17,7 @@ defmodule AtomicWeb.Components.Announcement do
- <.link navigate={Routes.organization_show_path(AtomicWeb.Endpoint, :show, @announcement.organization.id)} class="hover:underline focus:outline-none"> + <.link navigate={~p"/organizations/#{@announcement.organization.id}"} class="hover:underline focus:outline-none">

<%= @announcement.organization.name %>

@@ -31,12 +31,12 @@ defmodule AtomicWeb.Components.Announcement do

<%= @announcement.title %>

- <%= @announcement.description %> + <%= maybe_slice_string(@announcement.description, 300) %>
<%= if @announcement.image do %>
- +
<% end %> diff --git a/lib/atomic_web/components/avatar.ex b/lib/atomic_web/components/avatar.ex index 9dacd363a..2db005261 100644 --- a/lib/atomic_web/components/avatar.ex +++ b/lib/atomic_web/components/avatar.ex @@ -21,7 +21,7 @@ defmodule AtomicWeb.Components.Avatar do doc: "The size of the avatar." attr :color, :atom, - default: :primary, + default: :light_gray, values: [ :primary, :secondary, @@ -36,13 +36,13 @@ defmodule AtomicWeb.Components.Avatar do :light, :dark ], - doc: "Button color." + doc: "Avatar color." attr :class, :string, default: "", doc: "Additional classes to apply to the component." def avatar(assigns) do ~H""" - + <%= if @src do %> <% else %> @@ -56,7 +56,62 @@ defmodule AtomicWeb.Components.Avatar do """ end - defp generate_classes(assigns) do + attr :items, :list, required: true, doc: "The list of avatars to display." + + attr :spacing, :integer, + default: -1, + values: [-3, -2, -1, 0, 1, 2, 3], + doc: "The spacing between avatars." + + attr :type, :atom, + values: [:user, :organization, :company], + default: :user, + doc: "The type of entity associated with the avatars." + + attr :size, :atom, + values: [:xs, :sm, :md, :lg, :xl], + default: :md, + doc: "The size of the avatars." + + attr :color, :atom, + default: :light_gray, + values: [ + :primary, + :secondary, + :info, + :success, + :warning, + :danger, + :gray, + :light_gray, + :pure_white, + :white, + :light, + :dark + ], + doc: "Avatar color." + + attr :wrap, :boolean, default: false, doc: "Whether to wrap the avatars in a flex container." + + attr :class, :string, default: "", doc: "Additional classes to apply to the component." + + attr :avatar_class, :string, + default: "", + doc: "Additional classes to apply to the individual avatars." + + def avatar_group(assigns) do + ~H""" +
    + <%= for item <- @items do %> +
  • + <.avatar name={item[:name]} src={item[:src]} type={@type} size={@size} color={@color} class={"#{@avatar_class} atomic-avatar-grouped"} auto_generate_initials={Map.get(item, :auto_generate_initials, true)} /> +
  • + <% end %> +
+ """ + end + + defp generate_avatar_classes(assigns) do [ "atomic-avatar", assigns.src && "atomic-avatar--src", @@ -66,4 +121,16 @@ defmodule AtomicWeb.Components.Avatar do assigns.class ] end + + defp generate_avatar_group_spacing_class(spacing) do + %{ + -3 => "-space-x-3", + -2 => "-space-x-2", + -1 => "-space-x-1", + 0 => "space-x-0", + 1 => "space-x-1", + 2 => "space-x-2", + 3 => "space-x-3" + }[spacing] + end end diff --git a/lib/atomic_web/components/badge.ex b/lib/atomic_web/components/badge.ex index 309d571fd..f188c3215 100644 --- a/lib/atomic_web/components/badge.ex +++ b/lib/atomic_web/components/badge.ex @@ -24,19 +24,18 @@ defmodule AtomicWeb.Components.Badge do default: :left, doc: "The position of the icon if applicable." - attr :icon, :atom, default: nil, doc: "The icon to display." - - attr :icon_variant, :atom, - default: :outline, - values: [:solid, :outline, :mini], - doc: "The icon variation to display." - + attr :icon, :string, default: nil, doc: "The icon to display." attr :icon_class, :string, default: "", doc: "Additional classes to apply to the icon." attr :class, :string, default: "", doc: "Additional classes to apply to the badge." attr :label, :string, default: nil, doc: "Badge label." - attr :rest, :global - slot :inner_block, required: false + + attr :rest, :global, + include: + ~w(csrf_token disabled download form href hreflang method name navigate patch referrerpolicy rel replace target type value autofocus tabindex), + doc: "Arbitrary HTML or phx attributes." + + slot :inner_block, required: false, doc: "Slot for the content of the badge." def badge(assigns) do ~H""" @@ -50,11 +49,11 @@ defmodule AtomicWeb.Components.Badge do ]} > <%= if @icon && @icon_position == :left do %> - <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} solid={@icon_variant == :solid} mini={@icon_variant == :mini} /> + <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} /> <% end %> <%= render_slot(@inner_block) || @label %> <%= if @icon && @icon_position == :right do %> - <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} solid={@icon_variant == :solid} mini={@icon_variant == :mini} /> + <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} /> <% end %> """ diff --git a/lib/atomic_web/components/button.ex b/lib/atomic_web/components/button.ex index 12d441854..b87705367 100644 --- a/lib/atomic_web/components/button.ex +++ b/lib/atomic_web/components/button.ex @@ -45,13 +45,7 @@ defmodule AtomicWeb.Components.Button do default: :left, doc: "The position of the icon if applicable." - attr :icon, :atom, default: nil, doc: "The icon to display." - - attr :icon_variant, :atom, - default: :outline, - values: [:solid, :outline, :mini], - doc: "The icon variation to display." - + attr :icon, :string, default: nil, doc: "The icon to display." attr :icon_class, :string, default: "", doc: "Additional classes to apply to the icon." attr :class, :string, default: "", doc: "Additional classes to apply to the component." @@ -127,7 +121,7 @@ defmodule AtomicWeb.Components.Button do defp icon_content(assigns) do ~H""" - <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} solid={@icon_variant == :solid} mini={@icon_variant == :mini} /> + <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} /> """ end diff --git a/lib/atomic_web/components/calendar/calendar.ex b/lib/atomic_web/components/calendar/calendar.ex deleted file mode 100644 index 7c27db643..000000000 --- a/lib/atomic_web/components/calendar/calendar.ex +++ /dev/null @@ -1,383 +0,0 @@ -defmodule AtomicWeb.Components.Calendar do - @moduledoc false - use AtomicWeb, :component - - import AtomicWeb.CalendarUtils - import AtomicWeb.Components.CalendarMonth - import AtomicWeb.Components.CalendarWeek - import AtomicWeb.Components.Dropdown - - alias Timex.Duration - - attr :id, :string, default: "calendar", required: false - attr :current_path, :string, required: true - attr :activities, :list, required: true - attr :mode, :string, required: true - attr :timezone, :string, required: true - attr :params, :map, required: true - - def calendar( - %{ - current_path: current_path, - params: params, - mode: mode, - timezone: timezone - } = assigns - ) do - assigns = - assigns - |> assign_date(current_path, params, timezone) - - assigns = - case mode do - "week" -> - assigns - |> assigns_week(current_path, timezone, params) - - "month" -> - assigns - |> assigns_month(current_path, timezone, params) - - _ -> - assigns - |> assigns_month(current_path, timezone, params) - end - - ~H""" -
-
-
-
- - <%= if @mode == "month" do %> - - <% else %> - <%= case date_to_month(@beginning_of_week) == date_to_month(@end_of_week) do %> - <% true -> %> - - <% _ -> %> - <%= if date_to_year(@beginning_of_week) == date_to_year(@end_of_week) do %> - - <% else %> - - <% end %> - <% end %> - <% end %> - -
-
- <.link patch={"#{if @mode == "month" do @previous_month_path else @previous_week_path end}"}> - - - <.link patch={"#{if @mode == "month" do @present_month_path else @present_week_path end}"}> - - - <.link patch={"#{if @mode == "month" do @next_month_path else @next_week_path end}"}> - - -
- -
- -
-
- <.link patch={"#{if @mode == "month" do @present_month_path else @present_week_path end}"}> - - -
- <.link patch={@present_week_path}> - - - <.link patch={@present_month_path}> - - -
-
-
-
-
-
-
- <%= if @mode == "month" do %> - <.calendar_month current_path={@current_path} params={@params} activities={@activities} beginning_of_month={@beginning_of_month} end_of_month={@end_of_month} timezone={@timezone} /> - <% else %> - <.calendar_week current_path={@current_path} current={@current} params={@params} activities={@activities} beginning_of_week={@beginning_of_week} end_of_week={@end_of_week} timezone={@timezone} /> - <% end %> -
- """ - end - - defp assign_date(assigns, current_path, params, timezone) do - current = current_from_params(timezone, params) - - current_year = - current - |> date_to_year() - - current_month = - current - |> date_to_month() - - current_day = - current - |> date_to_day() - - present_year = - Timex.today(timezone) - |> date_to_year() - - present_month = - Timex.today(timezone) - |> date_to_month() - - present_day = - Timex.today(timezone) - |> date_to_day() - - present_week_path = - build_path(current_path, %{ - mode: "week", - day: present_day, - month: present_month, - year: present_year - }) - - current_week_path = - build_path(current_path, %{ - mode: "week", - day: current_day, - month: current_month, - year: current_year - }) - - present_month_path = - build_path(current_path, %{ - mode: "month", - day: present_day, - month: present_month, - year: present_year - }) - - current_month_path = - build_path(current_path, %{ - mode: "month", - day: current_day, - month: current_month, - year: current_year - }) - - assigns - |> assign(present_month_path: present_month_path) - |> assign(present_week_path: present_week_path) - |> assign(current_month_path: current_month_path) - |> assign(current_week_path: current_week_path) - |> assign(current: current) - end - - defp assigns_week(assigns, current_path, timezone, params) do - current = current_from_params(timezone, params) - beginning_of_week = Timex.beginning_of_week(current) - end_of_week = Timex.end_of_week(current) - - previous_week_date = - current - |> Timex.add(Duration.from_days(-7)) - - next_week_date = - current - |> Timex.add(Duration.from_days(7)) - - previous_week_day = - previous_week_date - |> date_to_day() - - previous_week_month = - previous_week_date - |> date_to_month() - - previous_week_year = - previous_week_date - |> date_to_year() - - next_week_day = - next_week_date - |> date_to_day() - - next_week_month = - next_week_date - |> date_to_month() - - next_week_year = - next_week_date - |> date_to_year() - - previous_week_path = - build_path(current_path, %{ - mode: "week", - day: previous_week_day, - month: previous_week_month, - year: previous_week_year - }) - - next_week_path = - build_path(current_path, %{ - mode: "week", - day: next_week_day, - month: next_week_month, - year: next_week_year - }) - - assigns - |> assign(beginning_of_week: beginning_of_week) - |> assign(end_of_week: end_of_week) - |> assign(previous_week_path: previous_week_path) - |> assign(next_week_path: next_week_path) - end - - defp assigns_month(assigns, current_path, timezone, params) do - current = current_from_params(timezone, params) - beginning_of_month = Timex.beginning_of_month(current) - end_of_month = Timex.end_of_month(current) - - last_day_previous_month = - beginning_of_month - |> Timex.add(Duration.from_days(-1)) - - first_day_next_month = - end_of_month - |> Timex.add(Duration.from_days(1)) - - previous_month = - last_day_previous_month - |> date_to_month() - - next_month = - first_day_next_month - |> date_to_month() - - previous_month_year = - last_day_previous_month - |> date_to_year() - - next_month_year = - first_day_next_month - |> date_to_year() - - previous_month_path = - build_path(current_path, %{mode: "month", month: previous_month, year: previous_month_year}) - - next_month_path = - build_path(current_path, %{mode: "month", month: next_month, year: next_month_year}) - - assigns - |> assign(beginning_of_month: beginning_of_month) - |> assign(end_of_month: end_of_month) - |> assign(previous_month_path: previous_month_path) - |> assign(next_month_path: next_month_path) - end -end diff --git a/lib/atomic_web/components/calendar/month.ex b/lib/atomic_web/components/calendar/month.ex deleted file mode 100644 index 6717e6ba5..000000000 --- a/lib/atomic_web/components/calendar/month.ex +++ /dev/null @@ -1,152 +0,0 @@ -defmodule AtomicWeb.Components.CalendarMonth do - @moduledoc false - use AtomicWeb, :component - - import AtomicWeb.CalendarUtils - import AtomicWeb.Components.Badge - - attr :id, :string, default: "calendar-month", required: false - attr :current_path, :string, required: true - attr :activities, :list, required: true - attr :timezone, :string, required: true - attr :beginning_of_month, :string, required: true - attr :end_of_month, :string, required: true - attr :params, :map, required: true - - def calendar_month(assigns) do - ~H""" -
-
-
- Mon -
-
- Tue -
-
- Wed -
-
- Thu -
-
- Fri -
-
- Sat -
-
- Sun -
-
-
-
- <%= for i <- 0..@end_of_month.day - 1 do %> - <.day index={i} params={@params} current_path={@current_path} activities={@activities} date={Timex.shift(@beginning_of_month, days: i)} timezone={@timezone} /> - <% end %> -
-
-
-
-
    - <%= for activity <- get_date_activities(@activities, current_from_params(@timezone, @params)) do %> - <.link patch={Routes.activity_show_path(AtomicWeb.Endpoint, :show, activity)}> -
  1. -
    -

    - <%= activity.title %> -

    -
    - <.link navigate={Routes.activity_index_path(AtomicWeb.Endpoint, :index)}> - <.badge variant={:outline} color={:primary} label="Activity" /> - - -
    -
    -
  2. - - <% end %> -
-
- """ - end - - defp day(%{index: index, date: date, timezone: timezone} = assigns) do - weekday = Timex.weekday(date, :monday) - today? = Timex.compare(date, Timex.today(timezone)) - - class = - class_list([ - {"relative py-2 px-3 lg:min-h-[110px] lg:flex hidden", true}, - {col_start(weekday), index == 0}, - {"bg-white", today? >= 0}, - {"bg-zinc-50 text-zinc-500", today? < 0} - ]) - - assigns = - assigns - |> assign(disabled: today? < 0) - |> assign(:text, Timex.format!(date, "{D}")) - |> assign(:class, class) - |> assign(:date, date) - |> assign(:today?, today?) - |> assign(:weekday, weekday) - - ~H""" -
- -
    - <%= for activity <- get_date_activities(@activities, @date) do %> -
  1. - <.link patch={Routes.activity_show_path(AtomicWeb.Endpoint, :show, activity)} class="group flex"> -

    - <%= activity.title %> -

    - - -
  2. - <% end %> -
-
- <.link patch={build_path(@current_path, %{mode: "month", day: date_to_day(@date), month: date_to_month(@date), year: date_to_year(@date)})} class={"#{if @index == 0 do col_start(@weekday) end} min-h-[56px] flex w-full flex-col bg-white px-3 py-2 text-zinc-900 hover:bg-zinc-100 focus:z-10 lg:hidden"}> - - <%= if (activities = get_date_activities(@activities, @date)) != [] do %> - <%= Enum.count(activities) %> events - - <%= for activity <- activities do %> - <%= if activity do %> - - <% end %> - <% end %> - - <% end %> - - """ - end -end diff --git a/lib/atomic_web/components/calendar/week.ex b/lib/atomic_web/components/calendar/week.ex deleted file mode 100644 index 2362b81eb..000000000 --- a/lib/atomic_web/components/calendar/week.ex +++ /dev/null @@ -1,175 +0,0 @@ -defmodule AtomicWeb.Components.CalendarWeek do - @moduledoc false - use AtomicWeb, :component - - alias Timex.Duration - - import AtomicWeb.CalendarUtils - - attr :id, :string, default: "calendar-week", required: false - attr :current_path, :string, required: true - attr :activities, :list, required: true - attr :timezone, :string, required: true - attr :current, :string, required: true - attr :beginning_of_week, :string, required: true - attr :end_of_week, :string, required: true - attr :params, :map, required: true - - def calendar_week(%{timezone: timezone} = assigns) do - assigns = - assigns - |> assign(week_mobile: ["M", "T", "W", "T", "F", "S", "S"]) - |> assign(week: ["Mon ", "Tue ", "Wed ", "Thu ", "Fri ", "Sat ", "Sun "]) - |> assign(today: Timex.today(timezone)) - - ~H""" -
-
-
-
- <%= for idx <- 0..6 do %> - <% day_of_week = @beginning_of_week |> Timex.add(Duration.from_days(idx)) %> - <.link patch={build_path(@current_path, %{"mode" => "week", "day" => day_of_week |> date_to_day(), "month" => @params["month"], "year" => @params["year"]})} class="flex flex-col items-center py-2"> - <%= Enum.at(@week_mobile, idx) %> - date_to_day() == @params["day"] do - "bg-zinc-900 rounded-full text-white" - else - "text-zinc-900" - end - end} flex items-center justify-center w-8 h-8 mt-1 font-semibold" - }> - <%= day_of_week |> date_to_day() %> - - - <% end %> -
- -
-
-
-
- -
-
- <%= for hour <- hours() do %> -
-
<%= hour %>
-
-
- <% end %> -
- - - -
    - <.day date={@current} idx={0} activities={@activities} /> -
- -
-
-
-
- """ - end - - defp day(assigns) do - ~H""" - <%= for activity <- get_date_activities(@activities, @date) do %> -
  • - <.link patch={Routes.activity_show_path(AtomicWeb.Endpoint, :show, activity)}> -
    -

    - <%= activity.title %> -

    -

    - -

    -
    - -
  • - <% end %> - """ - end - - defp calc_row_start(start) do - hours = - start - |> Timex.format!("{h24}") - |> String.to_integer() - - minutes = - start - |> Timex.format!("{m}") - |> String.to_integer() - - minutes = (minutes * 20 / 60) |> trunc() - - 2 + (hours - 8) * 20 + minutes - end - - defp calc_time(start, finish) do - time_diff = (NaiveDateTime.diff(finish, start) / 3600) |> trunc() - - 2 + 20 * time_diff - end - - defp hours, - do: [ - "8H", - "9H", - "10H", - "11H", - "12H", - "13H", - "14H", - "15H", - "16H", - "17H", - "18H", - "19H", - "20H", - "21H", - "22H" - ] -end diff --git a/lib/atomic_web/components/dropdown.ex b/lib/atomic_web/components/dropdown.ex index eb81121d3..e000815cf 100644 --- a/lib/atomic_web/components/dropdown.ex +++ b/lib/atomic_web/components/dropdown.ex @@ -1,5 +1,7 @@ defmodule AtomicWeb.Components.Dropdown do - @moduledoc false + @moduledoc """ + A customizable dropdown component for displaying a list of items, with flexible styling and behavior options. + """ use Phoenix.Component import AtomicWeb.Components.Icon @@ -9,11 +11,6 @@ defmodule AtomicWeb.Components.Dropdown do attr :items, :list, default: [], doc: "The items to display in the dropdown." - attr :icon_variant, :atom, - default: :outline, - values: [:solid, :outline, :mini], - doc: "The icon variation to display." - attr :orientation, :atom, default: :down, doc: "The orientation of the dropdown.", @@ -25,17 +22,42 @@ defmodule AtomicWeb.Components.Dropdown do def dropdown(assigns) do ~H""" -
    - <%= render_slot(@wrapper) %> -