diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..14a6785 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import './modern.css' layer(theme); \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 5e24308..92da708 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2,7 +2,7 @@ @import 'animate.css/animate.min.css'; @import 'tailwindcss'; -@import './theme.css'; +@import './theme.css' layer(theme); @layer base { *, @@ -302,6 +302,20 @@ url('/fonts/Roboto/roboto-v29-latin-900italic.svg#Roboto') format('svg'); /* Legacy iOS */ } +@font-face { + font-family: 'Montserrat'; + font-style: normal; + font-weight: 100 300 400 500 700 900; + src: url('/fonts/Montserrat/Montserrat-VariableFont_wght.ttf'); +} + +@font-face { + font-family: 'Montserrat'; + font-style: italic; + font-weight: 100 300 400 500 700 900; + src: url('/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf'); +} + .bg-gradient-animate { background: linear-gradient(-45deg, var(--color-secondary-500), var(--color-secondary-600), var(--color-primary-400), var(--color-primary-400)); background-size: 400% 400%; diff --git a/assets/css/modern.css b/assets/css/modern.css new file mode 100644 index 0000000..9dc3070 --- /dev/null +++ b/assets/css/modern.css @@ -0,0 +1,152 @@ +@theme { + /* Colors */ + /* Main colors */ + --color-*: initial; + --color-sky-blue: #81EBFE; + --color-teal: #14BFDB; + --color-azure: #29ACED; + --color-mauve: #8611ED; + --color-purple: #B80FEF; + --color-navy: #140553; + + /* Light colors */ + --color-cold-pink: #F3DEFA; + --color-warm-pink: #FFD2F6; + --color-yeallaw: #FFEAC2; + --color-pistacio: #D4FFF4; + --color-sky-tern: #C8EDFF; + --color-tealy: #81EBFE; + --color-earl-night: #0A7BB4; + --color-dark-night: #184992; + + /* Gradient */ + --gradient-primary: 123deg, var(--color-sky-blue) -36%, var(--color-teal) -12%, var(--color-azure) 21%, var(--color-mauve) 81%, var(--color-purple) 130%; + --gradient-secondary: 123deg, rgba(129, 235, 254, 0.9) -36%, rgba(20, 191, 219, 0.9) -12%, rgba(41, 172, 237, 0.9) 21%, rgba(134, 17, 237, 0.9) 81%, rgba(184, 15, 239, 0.9) 130%; + + /* Grayscale */ + --color-white: #FFFFFF; + --color-black: #191919; + --color-gray-20: #CCCCCC; + --color-gray-40: #9F9F9F; + --color-gray-60: #737373; + --color-gray-80: #464646; + --color-gray-100: #191919; + --color-gray-120: #000000; + + --color-platinum: #F0F0F0; + --color-platinum-20: #FFFFFF; + --color-platinum-40: #FCFCFC; + --color-platinum-60: #F8F8F8; + --color-platinum-80: #F4F4F4; + --color-platinum-100: #F0F0F0; + --color-platinum-120: #EEEEEE; + + /* Typography */ + --font-display: "Montserrat", sans-serif; + + /* Font Sizes - Desktop */ + --text-h1: 80px; + --leading-h1: 120%; + --font-weight-h1: 900; + + --text-h2: 40px; + --leading-h2: 120%; + --font-weight-h2: 700; + + --text-h3: 32px; + --leading-h3: 120%; + --font-weight-h3: 600; + + --text-h4: 24px; + --leading-h4: 120%; + --font-weight-h4: 500; + + --text-h5: 18px; + --leading-h5: 120%; + --font-weight-h5: 600; + + --text-h6: 16px; + --leading-h6: 120%; + --font-weight-h6: 500; + + --text-subheading: 20px; + --leading-subheading: 150%; + --font-weight-subheading: 300; + + --text-body-bold: 16px; + --leading-body-bold: 150%; + --font-weight-body-bold: 700; + + --text-body: 16px; + --leading-body: 150%; + --font-weight-body: 400; + + --text-small-body-bold: 14px; + --leading-small-body-bold: 150%; + --font-weight-small-body-bold: 600; + + --text-small-body: 14px; + --leading-small-body: 150%; + --font-weight-small-body: 400; + + --text-caption: 14px; + --leading-caption: 150%; + --font-weight-caption: 300; + + /* Font Sizes - Mobile */ + --text-mobile-h1: 32px; + --leading-mobile-h1: 120%; + --font-weight-mobile-h1: 900; + + --text-mobile-h2: 28px; + --leading-mobile-h2: 120%; + --font-weight-mobile-h2: 700; + + --text-mobile-h3: 22px; + --leading-mobile-h3: 120%; + --font-weight-mobile-h3: 600; + + --text-mobile-h4: 18px; + --leading-mobile-h4: 120%; + --font-weight-mobile-h4: 500; + + --text-mobile-h5: 16px; + --leading-mobile-h5: 120%; + --font-weight-mobile-h5: 600; + + --text-mobile-h6: 14px; + --leading-mobile-h6: 120%; + --font-weight-mobile-h6: 500; + + /* Spacing */ + --spacing-0: 0px; + --spacing-4: 4px; + --spacing-8: 8px; + --spacing-12: 12px; + --spacing-16: 16px; + --spacing-20: 20px; + --spacing-24: 24px; + --spacing-28: 28px; + --spacing-32: 32px; + --spacing-36: 36px; + --spacing-40: 40px; + --spacing-44: 44px; + --spacing-48: 48px; + --spacing-52: 52px; + --spacing-56: 56px; + --spacing-60: 60px; + --spacing-64: 64px; + --spacing-68: 68px; + --spacing-72: 72px; + --spacing-76: 76px; + --spacing-80: 80px; + --spacing-128: 128px; + + /* Font Weights */ + --font-weight-regular: 400; + --font-weight-bold: 700; + + /* Shadows */ + --shadow-3xl: 0px 902px 253px 0px rgba(65, 69, 124, 0.00), 0px 577px 231px 0px rgba(65, 69, 124, 0.01), 0px 325px 195px 0px rgba(65, 69, 124, 0.05), 0px 144px 144px 0px rgba(65, 69, 124, 0.09), 0px 36px 79px 0px rgba(65, 69, 124, 0.10) + +} \ No newline at end of file diff --git a/assets/css/theme.css b/assets/css/theme.css index dc8f31a..10987a5 100644 --- a/assets/css/theme.css +++ b/assets/css/theme.css @@ -1,4 +1,4 @@ -/* Theme variables based on tailwind.config.js */ + @theme { /* Primary Colors (water-blue) */ --color-primary-50: #E3F2FD; @@ -75,6 +75,7 @@ /* Font Family */ --font-family-sans: 'Roboto', sans-serif; --font-family-serif: 'Merriweather', serif; + --font-display: 'Montserrat', sans-serif; /* Box Shadows */ --shadow-base: 0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06); diff --git a/config/config.exs b/config/config.exs index 2a1dcbf..51120c3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,6 +44,13 @@ config :tailwind, --output=priv/static/assets/app.css ), cd: Path.expand("..", __DIR__) + ], + admin: [ + args: ~w( + --input=assets/css/admin.css + --output=priv/static/assets/admin.css + ), + cd: Path.expand("..", __DIR__) ] # Configure esbuild (the version is required) diff --git a/config/dev.exs b/config/dev.exs index 3c68d69..41198be 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -14,6 +14,7 @@ config :claper, ClaperWeb.Endpoint, # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}, + tailwind_admin: {Tailwind, :install_and_run, [:admin, ~w(--watch)]}, sass: { DartSass, :install_and_run, diff --git a/config/runtime.exs b/config/runtime.exs index d4dfdff..e5b490a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -128,7 +128,7 @@ allow_unlink_external_provider = logout_redirect_url = get_var_from_path_or_env(config_dir, "LOGOUT_REDIRECT_URL", nil) -languages = +languages = get_var_from_path_or_env(config_dir, "LANGUAGES", "en,fr,es") |> String.split(",") |> Enum.map(&String.trim/1) diff --git a/lib/claper/accounts.ex b/lib/claper/accounts.ex index 9bc56e4..4ad903c 100644 --- a/lib/claper/accounts.ex +++ b/lib/claper/accounts.ex @@ -43,7 +43,6 @@ defmodule Claper.Accounts do |> Repo.get_by(email: email) end - @doc """ Gets a user by email and creates a new user if the user does not exist. @@ -853,7 +852,9 @@ defmodule Claper.Accounts do """ def assign_role(%User{} = user, role_name) when is_binary(role_name) do case get_role_by_name(role_name) do - nil -> {:error, :role_not_found} + nil -> + {:error, :role_not_found} + role -> user |> Ecto.Changeset.change(%{role_id: role.id}) @@ -890,7 +891,9 @@ defmodule Claper.Accounts do """ def list_users_by_role(role_name) when is_binary(role_name) do case get_role_by_name(role_name) do - nil -> [] + nil -> + [] + role -> User |> where([u], u.role_id == ^role.id and is_nil(u.deleted_at)) diff --git a/lib/claper/accounts/guardian.ex b/lib/claper/accounts/guardian.ex index 6f3a303..618dcf9 100644 --- a/lib/claper/accounts/guardian.ex +++ b/lib/claper/accounts/guardian.ex @@ -3,15 +3,15 @@ defmodule Claper.Accounts.Guardian do Implementation module for Guardian authentication. This module handles JWT token generation and validation for user authentication. """ - + defmodule Plug do @moduledoc """ Plug helpers for Guardian authentication in tests. """ - + @doc """ Sign in a user to a conn. - + ## Parameters - conn: The connection - user: The user to sign in diff --git a/lib/claper/accounts/oidc/provider.ex b/lib/claper/accounts/oidc/provider.ex index 2459692..ba1c10f 100644 --- a/lib/claper/accounts/oidc/provider.ex +++ b/lib/claper/accounts/oidc/provider.ex @@ -3,19 +3,19 @@ defmodule Claper.Accounts.Oidc.Provider do import Ecto.Changeset @type t :: %__MODULE__{ - id: integer(), - name: String.t(), - issuer: String.t(), - client_id: String.t(), - client_secret: String.t(), - redirect_uri: String.t(), - scope: String.t(), - active: boolean(), - response_type: String.t(), - response_mode: String.t(), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() - } + id: integer(), + name: String.t(), + issuer: String.t(), + client_id: String.t(), + client_secret: String.t(), + redirect_uri: String.t(), + scope: String.t(), + active: boolean(), + response_type: String.t(), + response_mode: String.t(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } schema "oidc_providers" do field :name, :string @@ -36,10 +36,22 @@ defmodule Claper.Accounts.Oidc.Provider do """ def changeset(provider, attrs) do provider - |> cast(attrs, [:name, :issuer, :client_id, :client_secret, :redirect_uri, :scope, :active, :response_type, :response_mode]) + |> cast(attrs, [ + :name, + :issuer, + :client_id, + :client_secret, + :redirect_uri, + :scope, + :active, + :response_type, + :response_mode + ]) |> validate_required([:name, :issuer, :client_id, :client_secret, :redirect_uri]) |> validate_format(:issuer, ~r/^https?:\/\//, message: "must start with http:// or https://") - |> validate_format(:redirect_uri, ~r/^https?:\/\//, message: "must start with http:// or https://") + |> validate_format(:redirect_uri, ~r/^https?:\/\//, + message: "must start with http:// or https://" + ) |> unique_constraint(:name) end end diff --git a/lib/claper/accounts/role.ex b/lib/claper/accounts/role.ex index 10ab3e6..d039093 100644 --- a/lib/claper/accounts/role.ex +++ b/lib/claper/accounts/role.ex @@ -3,12 +3,12 @@ defmodule Claper.Accounts.Role do import Ecto.Changeset @type t :: %__MODULE__{ - id: integer(), - name: String.t(), - permissions: map(), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() - } + id: integer(), + name: String.t(), + permissions: map(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } schema "roles" do field :name, :string diff --git a/lib/claper/accounts/user.ex b/lib/claper/accounts/user.ex index 232c11f..d76e159 100644 --- a/lib/claper/accounts/user.ex +++ b/lib/claper/accounts/user.ex @@ -95,7 +95,7 @@ defmodule Claper.Accounts.User do defp validate_admin_password(changeset, opts) do password = get_change(changeset, :password) - + # Only validate password if it's provided if password && password != "" do changeset diff --git a/lib/claper/admin.ex b/lib/claper/admin.ex index 8ed9baf..49fe8ec 100644 --- a/lib/claper/admin.ex +++ b/lib/claper/admin.ex @@ -84,7 +84,6 @@ defmodule Claper.Admin do result = Repo.query!(sql, [period_sql_value, start_date, end_date]) - user_counts = result.rows |> Enum.map(fn [period, count] -> @@ -95,9 +94,11 @@ defmodule Claper.Admin do # Format data for charts labels = Enum.map(date_range, &format_date_label(&1, period)) - values = Enum.map(date_range, fn date -> - Map.get(user_counts, truncate_date(date, period), 0) - end) + + values = + Enum.map(date_range, fn date -> + Map.get(user_counts, truncate_date(date, period), 0) + end) %{ labels: labels, @@ -154,9 +155,11 @@ defmodule Claper.Admin do # Format data for charts labels = Enum.map(date_range, &format_date_label(&1, period)) - values = Enum.map(date_range, fn date -> - Map.get(event_counts, truncate_date(date, period), 0) - end) + + values = + Enum.map(date_range, fn date -> + Map.get(event_counts, truncate_date(date, period), 0) + end) %{ labels: labels, @@ -215,18 +218,22 @@ defmodule Claper.Admin do seven_days_ago = NaiveDateTime.add(now, -(7 * 24 * 60 * 60), :second) %{ - users_today: User + users_today: + User |> where([u], is_nil(u.deleted_at)) |> where([u], u.inserted_at >= ^twenty_four_hours_ago) |> Repo.aggregate(:count, :id), - events_today: Event + events_today: + Event |> where([e], e.inserted_at >= ^twenty_four_hours_ago) |> Repo.aggregate(:count, :id), - users_this_week: User + users_this_week: + User |> where([u], is_nil(u.deleted_at)) |> where([u], u.inserted_at >= ^seven_days_ago) |> Repo.aggregate(:count, :id), - events_this_week: Event + events_this_week: + Event |> where([e], e.inserted_at >= ^seven_days_ago) |> Repo.aggregate(:count, :id) } @@ -240,13 +247,17 @@ defmodule Claper.Admin do |> Date.range(NaiveDateTime.to_date(end_date)) |> Enum.to_list() |> case do - dates when period == :day -> dates - dates when period == :week -> dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1) - dates when period == :month -> dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys() + dates when period == :day -> + dates + + dates when period == :week -> + dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1) + + dates when period == :month -> + dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys() end end - defp period_sql(period) do case period do :day -> "day" @@ -265,13 +276,24 @@ defmodule Claper.Admin do defp truncate_date(date, period) do naive_date = NaiveDateTime.new!(date, ~T[00:00:00]) + case period do - :day -> NaiveDateTime.truncate(naive_date, :second) + :day -> + NaiveDateTime.truncate(naive_date, :second) + :week -> days_to_subtract = Date.day_of_week(date) - 1 - date |> Date.add(-days_to_subtract) |> NaiveDateTime.new!(~T[00:00:00]) |> NaiveDateTime.truncate(:second) + + date + |> Date.add(-days_to_subtract) + |> NaiveDateTime.new!(~T[00:00:00]) + |> NaiveDateTime.truncate(:second) + :month -> - date |> Date.beginning_of_month() |> NaiveDateTime.new!(~T[00:00:00]) |> NaiveDateTime.truncate(:second) + date + |> Date.beginning_of_month() + |> NaiveDateTime.new!(~T[00:00:00]) + |> NaiveDateTime.truncate(:second) end end diff --git a/lib/claper_web/components.ex b/lib/claper_web/components.ex new file mode 100644 index 0000000..de1b502 --- /dev/null +++ b/lib/claper_web/components.ex @@ -0,0 +1,21 @@ +defmodule ClaperWeb.Components do + @moduledoc """ + Provides UI components for the Claper application. + + ## Usage + + Import this module in your views or live views: + + import ClaperWeb.Components + + Then use the components: + + <.input id="email" name="email" type="email" placeholder="Enter your email" /> + <.button variant="primary">Submit + <.ui_label for="email">Email Address + """ + + defdelegate input(assigns), to: ClaperWeb.Components.Input + defdelegate button(assigns), to: ClaperWeb.Components.Button + defdelegate ui_label(assigns), to: ClaperWeb.Components.Label, as: :label +end diff --git a/lib/claper_web/components/button.ex b/lib/claper_web/components/button.ex new file mode 100644 index 0000000..67e21eb --- /dev/null +++ b/lib/claper_web/components/button.ex @@ -0,0 +1,88 @@ +defmodule ClaperWeb.Components.Button do + use ClaperWeb, :view_component + + attr :type, :string, default: "button" + + attr :variant, :string, + default: "primary", + values: ~w(primary secondary emphasis outline danger) + + attr :size, :string, default: "base", values: ~w(small base) + attr :disabled, :boolean, default: false + attr :loading, :boolean, default: false + attr :class, :string, default: "" + attr :rest, :global, include: ~w(form name value) + + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + defp button_classes(variant, size, disabled, loading, custom_class) do + base_classes = + "inline-flex items-center justify-center rounded-full transition-all duration-150 ease-in-out" + + variant_classes = + case variant do + "primary" -> + "bg-navy text-white hover:bg-navy/80" + + "secondary" -> + "bg-platinum text-navy hover:bg-platinum-80" + + "outline" -> + "bg-transparent text-navy ring-2 ring-navy hover:ring-3" + + "danger" -> + "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500" + + "emphasis" -> + "text-white bg-linear-(--gradient-primary) hover:bg-linear-(--gradient-secondary)" + + _ -> + "" + end + + size_classes = + case size do + "small" -> "px-12 py-4 font-small-body-bold text-small-body-bold" + "base" -> "px-16 py-8 font-small-body-bold text-small-body-bold md:px-24 md:py-12 md:font-body-bold md:text-body-bold" + _ -> "" + end + + state_classes = + cond do + disabled or loading -> "opacity-50 cursor-not-allowed" + true -> "cursor-pointer" + end + + "#{base_classes} #{variant_classes} #{size_classes} #{state_classes} #{custom_class}" + end +end diff --git a/lib/claper_web/components/input.ex b/lib/claper_web/components/input.ex new file mode 100644 index 0000000..033f773 --- /dev/null +++ b/lib/claper_web/components/input.ex @@ -0,0 +1,69 @@ +defmodule ClaperWeb.Components.Input do + use ClaperWeb, :view_component + alias ClaperWeb.Components.Label + + attr :id, :string, required: true + attr :name, :string, required: true + attr :label, :string, default: nil + attr :type, :string, default: "text" + attr :value, :string, default: "" + attr :placeholder, :string, default: "" + attr :required, :boolean, default: false + attr :disabled, :boolean, default: false + attr :errors, :list, default: [] + attr :class, :string, default: "" + attr :rest, :global, include: ~w(autocomplete pattern minlength maxlength) + + def input(assigns) do + ~H""" +
{error}
+ <% end %> +| <%= event.name %> | -<%= event.code %> | -<%= event.user.email %> | -<%= Calendar.strftime(event.started_at, "%b %d, %Y") %> | -<%= event.audience_peak %> | -<%= elem(status, 0) %> | +{event.name} | +{event.code} | +{event.user.email} | ++ {Calendar.strftime(event.started_at, "%b %d, %Y")} + | +{event.audience_peak} | ++ {elem(status, 0)} + |
- <.link href={~p"/admin/events/#{event}"} class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors">
+ <.link
+ href={~p"/admin/events/#{event}"}
+ class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"
+ >
View
- <.link href={~p"/admin/events/#{event}/edit"} class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors">
+ <.link
+ href={~p"/admin/events/#{event}/edit"}
+ class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"
+ >
Edit
@@ -103,4 +158,4 @@
|