diff --git a/Makefile b/Makefile index c4be98db4..26322ad7c 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,8 @@ stop: @bash docker/message.sh "Mobilizon is stopped" test: stop @bash docker/message.sh "Running tests" - docker compose -f docker compose.yml -f docker compose.test.yml run api mix prepare_test - docker compose -f docker compose.yml -f docker compose.test.yml run api mix test $(only) + docker compose -f docker-compose.yml -f docker-compose.test.yml run api mix prepare_test + docker compose -f docker-compose.yml -f docker-compose.test.yml run api mix test $(only) @bash docker/message.sh "Done running tests" format: docker compose run --rm api bash -c "mix format && mix credo --strict" diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex index fd0f81f5a..76bb1a362 100644 --- a/lib/graphql/resolvers/admin.ex +++ b/lib/graphql/resolvers/admin.ex @@ -5,11 +5,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do import Mobilizon.Users.Guards - alias Mobilizon.{Actors, Admin, Config, Events, Instances, Users} + alias Mobilizon.{Actors, Admin, Config, Events, Instances, Media, Users} alias Mobilizon.Actors.{Actor, Follower} - alias Mobilizon.Admin.{ActionLog, Setting} + alias Mobilizon.Admin.{ActionLog, Setting, SettingMedia} alias Mobilizon.Cldr.Language - alias Mobilizon.Config alias Mobilizon.Discussions.Comment alias Mobilizon.Events.Event alias Mobilizon.Federation.ActivityPub.{Actions, Relay} @@ -20,6 +19,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do alias Mobilizon.Storage.Page alias Mobilizon.Users.User alias Mobilizon.Web.Email + + alias Mobilizon.GraphQL.Resolvers.Media, as: MediaResolver + import Mobilizon.Web.Gettext require Logger @@ -268,8 +270,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do with {:ok, res} <- Admin.save_settings("instance", args), res <- res - |> Enum.map(fn {key, %Setting{value: value}} -> - {key, Admin.get_setting_value(value)} + |> Enum.map(fn {key, val} -> + case val do + %Setting{value: value} -> {key, Admin.get_setting_value(value)} + %SettingMedia{media: media} -> {key, media} + end end) |> Enum.into(%{}), :ok <- eventually_update_instance_actor(res) do @@ -284,6 +289,38 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do dgettext("errors", "You need to be logged-in and an administrator to save admin settings")} end + @spec get_media_setting(any(), any(), Absinthe.Resolution.t()) :: + {:ok, Media.t()} | {:error, String.t()} + def get_media_setting(_parent, %{group: group, name: name}, %{ + context: %{current_user: %User{role: role}} + }) + when is_admin(role) do + {:ok, MediaResolver.transform_media(Admin.get_admin_setting_media(group, name, nil))} + end + + def get_media_setting(_parent, _args, _resolution) do + {:error, + dgettext("errors", "You need to be logged-in and an administrator to access admin settings")} + end + + @spec get_instance_logo(any(), any(), Absinthe.Resolution.t()) :: + {:ok, Media.t() | nil} | {:error, String.t()} + def get_instance_logo(parent, _args, resolution) do + get_media_setting(parent, %{group: "instance", name: "instance_logo"}, resolution) + end + + @spec get_instance_favicon(any(), any(), Absinthe.Resolution.t()) :: + {:ok, Media.t() | nil} | {:error, String.t()} + def get_instance_favicon(parent, _args, resolution) do + get_media_setting(parent, %{group: "instance", name: "instance_favicon"}, resolution) + end + + @spec get_default_picture(any(), any(), Absinthe.Resolution.t()) :: + {:ok, Media.t() | nil} | {:error, String.t()} + def get_default_picture(parent, _args, resolution) do + get_media_setting(parent, %{group: "instance", name: "default_picture"}, resolution) + end + @spec update_user(any, map(), Absinthe.Resolution.t()) :: {:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()} | {:ok, Mobilizon.Users.User.t()} diff --git a/lib/graphql/resolvers/config.ex b/lib/graphql/resolvers/config.ex index c4ed072f3..10f90849d 100644 --- a/lib/graphql/resolvers/config.ex +++ b/lib/graphql/resolvers/config.ex @@ -5,8 +5,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do alias Mobilizon.Config alias Mobilizon.Events.Categories + alias Mobilizon.Medias.Media alias Mobilizon.Service.{AntiSpam, FrontEndAnalytics} + alias Mobilizon.GraphQL.Resolvers.Media, as: MediaResolver + @doc """ Gets config. """ @@ -31,6 +34,16 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do {:ok, data} end + @spec instance_logo(any(), map(), Absinthe.Resolution.t()) :: {:ok, Media.t()} + def instance_logo(_parent, _params, _resolution) do + {:ok, MediaResolver.transform_media(Config.instance_logo())} + end + + @spec default_picture(any(), map(), Absinthe.Resolution.t()) :: {:ok, Media.t()} + def default_picture(_parent, _params, _resolution) do + {:ok, MediaResolver.transform_media(Config.default_picture())} + end + @spec terms(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()} def terms(_parent, %{locale: locale}, _resolution) do type = Config.instance_terms_type() @@ -99,6 +112,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do long_description: Config.instance_long_description(), slogan: Config.instance_slogan(), languages: Config.instance_languages(), + instance_logo: Config.instance_logo(), + primary_color: Config.primary_color(), + secondary_color: Config.secondary_color(), + default_picture: Config.default_picture(), anonymous: %{ participation: %{ allowed: Config.anonymous_participation?(), diff --git a/lib/graphql/resolvers/media.ex b/lib/graphql/resolvers/media.ex index 579403799..7647657df 100644 --- a/lib/graphql/resolvers/media.ex +++ b/lib/graphql/resolvers/media.ex @@ -18,6 +18,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do do_fetch_media(media_id) end + def media(%{media_id: media_id} = _parent, _args, _resolution) do + do_fetch_media(media_id) + end + def media(%{picture: media} = _parent, _args, _resolution), do: {:ok, media} def media(_parent, %{id: media_id}, _resolution), do: do_fetch_media(media_id) def media(_parent, _args, _resolution), do: {:ok, nil} @@ -133,8 +137,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated} - @spec transform_media(Media.t()) :: map() - defp transform_media(%Media{id: id, file: file, metadata: metadata}) do + @spec transform_media(Media.t() | nil) :: map() | nil + def transform_media(nil), do: nil + + def transform_media(%Media{id: id, file: file, metadata: metadata}) do %{ name: file.name, url: file.url, diff --git a/lib/graphql/schema/admin.ex b/lib/graphql/schema/admin.ex index 59c6b810b..c5f2beb9a 100644 --- a/lib/graphql/schema/admin.ex +++ b/lib/graphql/schema/admin.ex @@ -124,6 +124,24 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do field(:instance_terms_type, :instance_terms_type, description: "The instance's terms type") field(:instance_terms_url, :string, description: "The instance's terms URL") + field(:instance_logo, :media, + description: "The instance's logo", + resolve: &Admin.get_instance_logo/3 + ) + + field(:instance_favicon, :media, + description: "The instance's favicon", + resolve: &Admin.get_instance_favicon/3 + ) + + field(:default_picture, :media, + description: "The default picture", + resolve: &Admin.get_default_picture/3 + ) + + field(:primary_color, :string, description: "The instance's primary color") + field(:secondary_color, :string, description: "The instance's secondary color") + field(:instance_privacy_policy, :string, description: "The instance's privacy policy body text" ) @@ -412,6 +430,25 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do arg(:instance_long_description, :string, description: "The instance's long description") arg(:instance_slogan, :string, description: "The instance's slogan") arg(:contact, :string, description: "The instance's contact details") + + arg(:instance_logo, :media_input, + description: + "The instance's logo, either as an object or directly the ID of an existing media" + ) + + arg(:instance_favicon, :media_input, + description: + "The instance's favicon, either as an object or directly the ID of an existing media" + ) + + arg(:default_picture, :media_input, + description: + "The default picture, either as an object or directly the ID of an existing media" + ) + + arg(:primary_color, :string, description: "The instance's primary color") + arg(:secondary_color, :string, description: "The instance's secondary color") + arg(:instance_terms, :string, description: "The instance's terms body text") arg(:instance_terms_type, :instance_terms_type, description: "The instance's terms type") arg(:instance_terms_url, :string, description: "The instance's terms URL") diff --git a/lib/graphql/schema/config.ex b/lib/graphql/schema/config.ex index 41c4b086c..b4e68180a 100644 --- a/lib/graphql/schema/config.ex +++ b/lib/graphql/schema/config.ex @@ -60,6 +60,17 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do resolve(&Config.terms/3) end + field(:instance_logo, :media, description: "The instance's logo") do + resolve(&Config.instance_logo/3) + end + + field(:default_picture, :media, description: "The default picture") do + resolve(&Config.default_picture/3) + end + + field(:primary_color, :string, description: "The instance's primary color") + field(:secondary_color, :string, description: "The instance's secondary color") + field(:privacy, :privacy, description: "The instance's privacy policy") do arg(:locale, :string, default_value: "en", diff --git a/lib/mobilizon/admin/admin.ex b/lib/mobilizon/admin/admin.ex index 9a93b02f1..16ae49aa0 100644 --- a/lib/mobilizon/admin/admin.ex +++ b/lib/mobilizon/admin/admin.ex @@ -9,7 +9,8 @@ defmodule Mobilizon.Admin do alias Mobilizon.Actors.Actor alias Mobilizon.{Admin, Users} alias Mobilizon.Admin.ActionLog - alias Mobilizon.Admin.Setting + alias Mobilizon.Admin.{Setting, SettingMedia} + alias Mobilizon.Medias.Media alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users.User @@ -78,9 +79,47 @@ defmodule Mobilizon.Admin do defp stringify_struct(struct), do: struct - @spec get_all_admin_settings :: list(Setting.t()) + @spec get_all_admin_settings :: map() def get_all_admin_settings do - Repo.all(Setting) + medias = + SettingMedia + |> Repo.all() + |> Repo.preload(:media) + |> Enum.map(fn %SettingMedia{group: group, name: name, media: media} -> + {group, name, media} + end) + + values = + Setting + |> Repo.all() + |> Enum.map(fn %Setting{group: group, name: name, value: value} -> + {group, name, get_setting_value(value)} + end) + + all_settings = Enum.concat(values, medias) + + Enum.reduce( + all_settings, + %{}, + # For each {group,name,value} + fn {group, name, value}, acc -> + # We update the %{group: map} in the accumulator + {_, new_acc} = + Map.get_and_update( + acc, + group, + # We put the %{name: value} into the %{group: map} + fn group_map -> + { + group_map, + Map.put(group_map || %{}, name, value) + } + end + ) + + new_acc + end + ) end @spec get_admin_setting_value(String.t(), String.t(), String.t() | nil) :: @@ -119,21 +158,40 @@ defmodule Mobilizon.Admin do end end + @spec get_admin_setting_media(String.t(), String.t(), String.t() | nil) :: + {:ok, Media.t()} | {:error, :not_found} | nil + def get_admin_setting_media(group, name, fallback \\ nil) + when is_binary(group) and is_binary(name) do + case SettingMedia + |> where(group: ^group) + |> where(name: ^name) + |> preload(:media) + |> Repo.one() do + nil -> + fallback + + %SettingMedia{media: media} -> + media + + %SettingMedia{} -> + fallback + end + end + @spec save_settings(String.t(), map()) :: {:ok, any} | {:error, any} def save_settings(group, args) do + {medias, values} = Map.split(args, [:instance_logo, :instance_favicon, :default_picture]) + Multi.new() - |> do_save_setting(group, args) + |> do_save_media_setting(group, medias) + |> do_save_value_setting(group, values) |> Repo.transaction() end - def clear_settings(group) do - Setting |> where([s], s.group == ^group) |> Repo.delete_all() - end + @spec do_save_value_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t() + defp do_save_value_setting(transaction, _group, args) when args == %{}, do: transaction - @spec do_save_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t() - defp do_save_setting(transaction, _group, args) when args == %{}, do: transaction - - defp do_save_setting(transaction, group, args) do + defp do_save_value_setting(transaction, group, args) do key = hd(Map.keys(args)) {val, rest} = Map.pop(args, key) @@ -150,7 +208,40 @@ defmodule Mobilizon.Admin do conflict_target: [:group, :name] ) - do_save_setting(transaction, group, rest) + do_save_value_setting(transaction, group, rest) + end + + @spec do_save_media_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t() + defp do_save_media_setting(transaction, _group, args) when args == %{}, do: transaction + + defp do_save_media_setting(transaction, group, args) do + key = hd(Map.keys(args)) + {val, rest} = Map.pop(args, key) + + transaction = + case val do + val -> + Multi.insert( + transaction, + key, + SettingMedia.changeset(%SettingMedia{}, %{ + group: group, + name: Atom.to_string(key), + media: val + }), + on_conflict: :replace_all, + conflict_target: [:group, :name] + ) + end + + do_save_media_setting(transaction, group, rest) + end + + def clear_settings(group) do + Multi.new() + |> Multi.delete_all(:settings, Setting |> where([s], s.group == ^group)) + |> Multi.delete_all(:settings_medias, SettingMedia |> where([s], s.group == ^group)) + |> Repo.transaction() end @spec convert_to_string(any()) :: String.t() diff --git a/lib/mobilizon/admin/setting.ex b/lib/mobilizon/admin/setting.ex index a505358ec..cf363a61c 100644 --- a/lib/mobilizon/admin/setting.ex +++ b/lib/mobilizon/admin/setting.ex @@ -4,6 +4,7 @@ defmodule Mobilizon.Admin.Setting do """ use Ecto.Schema import Ecto.Changeset + alias Ecto.Changeset @required_attrs [:group, :name] @optional_attrs [:value] @@ -32,3 +33,93 @@ defmodule Mobilizon.Admin.Setting do |> unique_constraint(:group, name: :admin_settings_group_name_index) end end + +defmodule Mobilizon.Admin.SettingMedia do + @moduledoc """ + A Key-Value settings table for media settings + """ + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Mobilizon.Federation.ActivityPub.Relay + alias Mobilizon.Medias + alias Mobilizon.Medias.Media + alias Mobilizon.Storage.Repo + + @required_attrs [:group, :name] + + @type t :: %{ + group: String.t(), + name: String.t(), + media: Media.t() + } + + schema "admin_settings_medias" do + field(:group, :string) + field(:name, :string) + belongs_to(:media, Media, on_replace: :delete) + + timestamps() + end + + @doc false + @spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t() + def changeset(setting_media, attrs) do + setting_media + |> Repo.preload(:media) + |> cast(attrs, @required_attrs) + |> put_media(attrs) + |> validate_required(@required_attrs) + |> unique_constraint(:group, name: :admin_settings_medias_group_name_index) + end + + # # In case the provided media is an existing one + @spec put_media(Changeset.t(), map) :: Changeset.t() + defp put_media(%Changeset{} = changeset, %{media: %{media_id: id}}) do + %Media{} = media = Medias.get_media!(id) + put_assoc(changeset, :media, media) + end + + # In case it's a new media + defp put_media(%Changeset{} = changeset, %{media: %{media: media}}) do + {:ok, media} = upload_media(media) + put_assoc(changeset, :media, media) + end + + # In case there is no media + defp put_media(%Changeset{} = changeset, _media) do + put_assoc(changeset, :media, nil) + end + + import Mobilizon.Web.Gettext + @spec upload_media(map) :: {:ok, Media.t()} | {:error, any} + defp upload_media(%{file: %Plug.Upload{} = file} = args) do + with {:ok, + %{ + name: _name, + url: url, + content_type: content_type, + size: size + } = uploaded} <- + Mobilizon.Web.Upload.store(file), + args <- + args + |> Map.put(:url, url) + |> Map.put(:size, size) + |> Map.put(:content_type, content_type), + {:ok, media = %Media{}} <- + Medias.create_media(%{ + file: args, + actor_id: Map.get(args, :actor_id, Relay.get_actor().id), + metadata: Map.take(uploaded, [:width, :height, :blurhash]) + }) do + {:ok, media} + else + {:error, :mime_type_not_allowed} -> + {:error, dgettext("errors", "File doesn't have an allowed MIME type.")} + + error -> + {:error, error} + end + end +end diff --git a/lib/mobilizon/config.ex b/lib/mobilizon/config.ex index 8b9db6a71..dc52baa1c 100644 --- a/lib/mobilizon/config.ex +++ b/lib/mobilizon/config.ex @@ -4,7 +4,8 @@ defmodule Mobilizon.Config do """ alias Mobilizon.Actors - alias Mobilizon.Admin.Setting + alias Mobilizon.Admin + alias Mobilizon.Medias.Media alias Mobilizon.Service.GitStatus require Logger import Mobilizon.Service.Export.Participants.Common, only: [enabled_formats: 0] @@ -29,56 +30,18 @@ defmodule Mobilizon.Config do @spec instance_config :: mobilizon_config def instance_config, do: Application.get_env(:mobilizon, :instance) - @spec db_instance_config :: list(Setting.t()) - def db_instance_config, do: Mobilizon.Admin.get_all_admin_settings() - @spec config_cache :: map() def config_cache do - case Cachex.fetch(:config, :all_db_config, fn _key -> - value = - Enum.reduce( - Mobilizon.Admin.get_all_admin_settings(), - %{}, - &arrange_values/2 - ) - - {:commit, value} - end) do + case Cachex.fetch( + :config, + :all_db_config, + fn _key -> {:commit, Admin.get_all_admin_settings()} end + ) do {status, value} when status in [:ok, :commit] -> value _err -> %{} end end - @spec arrange_values(Setting.t(), map()) :: map() - defp arrange_values(setting, acc) do - {_, new_data} = - Map.get_and_update(acc, setting.group, fn current_value -> - new_value = current_value || %{} - - {current_value, Map.put(new_value, setting.name, process_value(setting.value))} - end) - - new_data - end - - @spec process_value(String.t() | nil) :: any() - defp process_value(nil), do: nil - defp process_value(""), do: nil - - defp process_value(value) do - case Jason.decode(value) do - {:ok, val} -> - val - - {:error, _} -> - case value do - "true" -> true - "false" -> false - value -> value - end - end - end - @spec config_cached_value(String.t(), String.t(), String.t()) :: any() def config_cached_value(group, name, fallback \\ nil) do config_cache() @@ -115,10 +78,23 @@ defmodule Mobilizon.Config do @spec instance_slogan :: String.t() | nil def instance_slogan, do: config_cached_value("instance", "instance_slogan") + @spec instance_logo :: Media.t() | nil + def instance_logo, do: config_cached_value("instance", "instance_logo") + + @spec instance_favicon :: Media.t() | nil + def instance_favicon, do: config_cached_value("instance", "instance_favicon") + + @spec default_picture :: Media.t() | nil + def default_picture, do: config_cached_value("instance", "default_picture") + + @spec primary_color :: Media.t() | nil + def primary_color, do: config_cached_value("instance", "primary_color") + + @spec secondary_color :: Media.t() | nil + def secondary_color, do: config_cached_value("instance", "secondary_color") + @spec contact :: String.t() | nil - def contact do - config_cached_value("instance", "contact") - end + def contact, do: config_cached_value("instance", "contact") @spec instance_terms(String.t()) :: String.t() def instance_terms(locale \\ "en") do @@ -473,6 +449,9 @@ defmodule Mobilizon.Config do instance_slogan: instance_slogan(), registrations_open: instance_registrations_open?(), contact: contact(), + primary_color: primary_color(), + secondary_color: secondary_color(), + instance_logo: instance_logo(), instance_terms: instance_terms(), instance_terms_type: instance_terms_type(), instance_terms_url: instance_terms_url(), diff --git a/lib/mobilizon/medias/medias.ex b/lib/mobilizon/medias/medias.ex index 29cccd780..06c8ce500 100644 --- a/lib/mobilizon/medias/medias.ex +++ b/lib/mobilizon/medias/medias.ex @@ -185,7 +185,8 @@ defmodule Mobilizon.Medias do [from: "events_medias", param: "media_id"], [from: "posts", param: "picture_id"], [from: "posts_medias", param: "media_id"], - [from: "comments_medias", param: "media_id"] + [from: "comments_medias", param: "media_id"], + [from: "admin_settings_medias", param: "media_id"] ] |> Enum.map_join(" UNION ", fn [from: from, param: param] -> "SELECT 1 FROM #{from} WHERE #{from}.#{param} = m0.id" diff --git a/lib/web/controllers/manifest_controller.ex b/lib/web/controllers/manifest_controller.ex new file mode 100644 index 000000000..c26f841f9 --- /dev/null +++ b/lib/web/controllers/manifest_controller.ex @@ -0,0 +1,58 @@ +defmodule Mobilizon.Web.ManifestController do + use Mobilizon.Web, :controller + + alias Mobilizon.Config + alias Mobilizon.Medias.Media + + @spec manifest(Plug.Conn.t(), any) :: Plug.Conn.t() + def manifest(conn, _params) do + favicons = + case Config.instance_favicon() do + %Media{file: %{url: url}, metadata: metadata} -> + [ + Map.merge( + %{ + src: url + }, + case metadata do + %{width: width} -> %{sizes: "#{width}x#{width}"} + _ -> %{} + end + ) + ] + + _ -> + [ + %{ + src: "./img/icons/android-chrome-512x512.png", + sizes: "512x512", + type: "image/png" + }, + %{ + src: "./img/icons/android-chrome-192x192.png", + sizes: "192x192", + type: "image/png" + } + ] + end + + json(conn, %{ + name: Config.instance_name(), + start_url: "/", + scope: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#ffd599", + orientation: "portrait-primary", + icons: favicons + }) + end + + @spec favicon(Plug.Conn.t(), any) :: Plug.Conn.t() + def favicon(conn, _params) do + case Config.instance_favicon() do + %Media{file: %{url: url}} -> redirect(conn, external: url) + _ -> redirect(conn, to: "/img/icons/favicon.ico") + end + end +end diff --git a/lib/web/mobilizon_web.ex b/lib/web/mobilizon_web.ex index a1f97a6e3..9848b880b 100644 --- a/lib/web/mobilizon_web.ex +++ b/lib/web/mobilizon_web.ex @@ -18,8 +18,7 @@ defmodule Mobilizon.Web do """ def static_paths, - do: - ~w(index.html manifest.json manifest.webmanifest service-worker.js css fonts img js favicon.ico robots.txt assets) + do: ~w(index.html service-worker.js css fonts img js robots.txt assets) def controller do quote do diff --git a/lib/web/router.ex b/lib/web/router.ex index bc50ead97..a960fba36 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -113,6 +113,12 @@ defmodule Mobilizon.Web.Router do get("/nodeinfo/:version", NodeInfoController, :nodeinfo) end + scope "/", Mobilizon.Web do + get("/manifest.webmanifest", ManifestController, :manifest) + get("/manifest.json", ManifestController, :manifest) + get("/favicon.ico", ManifestController, :favicon) + end + scope "/", Mobilizon.Web do pipe_through(:activity_pub_and_html) pipe_through(:activity_pub_signature) diff --git a/lib/web/templates/page/index.html.heex b/lib/web/templates/page/index.html.heex index 620bb39fd..7cf7efa13 100644 --- a/lib/web/templates/page/index.html.heex +++ b/lib/web/templates/page/index.html.heex @@ -4,15 +4,16 @@ - + + <%= if root?(assigns) do %> @@ -24,6 +25,7 @@ <%= Vite.vite_client() %> <%= Vite.vite_snippet("src/main.ts") %> +