Merge branch '1397-instance-personnalization' into 'main'

Draft: enable instance personnalization

Closes #1087 and #1397

See merge request framasoft/mobilizon!1538
This commit is contained in:
ppom 2024-03-04 18:09:02 +00:00
commit 37afc27ec7
109 changed files with 951 additions and 198 deletions

View File

@ -19,8 +19,8 @@ stop:
@bash docker/message.sh "Mobilizon is stopped" @bash docker/message.sh "Mobilizon is stopped"
test: stop test: stop
@bash docker/message.sh "Running tests" @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 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 test $(only)
@bash docker/message.sh "Done running tests" @bash docker/message.sh "Done running tests"
format: format:
docker compose run --rm api bash -c "mix format && mix credo --strict" docker compose run --rm api bash -c "mix format && mix credo --strict"

View File

@ -5,11 +5,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
import Mobilizon.Users.Guards 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.Actors.{Actor, Follower}
alias Mobilizon.Admin.{ActionLog, Setting} alias Mobilizon.Admin.{ActionLog, Setting, SettingMedia}
alias Mobilizon.Cldr.Language alias Mobilizon.Cldr.Language
alias Mobilizon.Config
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Relay} alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
@ -20,6 +19,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Web.Email alias Mobilizon.Web.Email
alias Mobilizon.GraphQL.Resolvers.Media, as: MediaResolver
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
require Logger require Logger
@ -268,8 +270,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
with {:ok, res} <- Admin.save_settings("instance", args), with {:ok, res} <- Admin.save_settings("instance", args),
res <- res <-
res res
|> Enum.map(fn {key, %Setting{value: value}} -> |> Enum.map(fn {key, val} ->
{key, Admin.get_setting_value(value)} case val do
%Setting{value: value} -> {key, Admin.get_setting_value(value)}
%SettingMedia{media: media} -> {key, media}
end
end) end)
|> Enum.into(%{}), |> Enum.into(%{}),
:ok <- eventually_update_instance_actor(res) do :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")} dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
end 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()) :: @spec update_user(any, map(), Absinthe.Resolution.t()) ::
{:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()} {:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()}
| {:ok, Mobilizon.Users.User.t()} | {:ok, Mobilizon.Users.User.t()}

View File

@ -5,8 +5,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
alias Mobilizon.Config alias Mobilizon.Config
alias Mobilizon.Events.Categories alias Mobilizon.Events.Categories
alias Mobilizon.Medias.Media
alias Mobilizon.Service.{AntiSpam, FrontEndAnalytics} alias Mobilizon.Service.{AntiSpam, FrontEndAnalytics}
alias Mobilizon.GraphQL.Resolvers.Media, as: MediaResolver
@doc """ @doc """
Gets config. Gets config.
""" """
@ -31,6 +34,16 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
{:ok, data} {:ok, data}
end 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()} @spec terms(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()}
def terms(_parent, %{locale: locale}, _resolution) do def terms(_parent, %{locale: locale}, _resolution) do
type = Config.instance_terms_type() type = Config.instance_terms_type()
@ -98,6 +111,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
long_description: Config.instance_long_description(), long_description: Config.instance_long_description(),
slogan: Config.instance_slogan(), slogan: Config.instance_slogan(),
languages: Config.instance_languages(), 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: %{ anonymous: %{
participation: %{ participation: %{
allowed: Config.anonymous_participation?(), allowed: Config.anonymous_participation?(),

View File

@ -18,6 +18,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
do_fetch_media(media_id) do_fetch_media(media_id)
end 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(%{picture: media} = _parent, _args, _resolution), do: {:ok, media}
def media(_parent, %{id: media_id}, _resolution), do: do_fetch_media(media_id) def media(_parent, %{id: media_id}, _resolution), do: do_fetch_media(media_id)
def media(_parent, _args, _resolution), do: {:ok, nil} 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} def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
@spec transform_media(Media.t()) :: map() @spec transform_media(Media.t() | nil) :: map() | nil
defp transform_media(%Media{id: id, file: file, metadata: metadata}) do def transform_media(nil), do: nil
def transform_media(%Media{id: id, file: file, metadata: metadata}) do
%{ %{
name: file.name, name: file.name,
url: file.url, url: file.url,

View File

@ -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_type, :instance_terms_type, description: "The instance's terms type")
field(:instance_terms_url, :string, description: "The instance's terms URL") 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, field(:instance_privacy_policy, :string,
description: "The instance's privacy policy body text" 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_long_description, :string, description: "The instance's long description")
arg(:instance_slogan, :string, description: "The instance's slogan") arg(:instance_slogan, :string, description: "The instance's slogan")
arg(:contact, :string, description: "The instance's contact details") 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, :string, description: "The instance's terms body text")
arg(:instance_terms_type, :instance_terms_type, description: "The instance's terms type") arg(:instance_terms_type, :instance_terms_type, description: "The instance's terms type")
arg(:instance_terms_url, :string, description: "The instance's terms URL") arg(:instance_terms_url, :string, description: "The instance's terms URL")

View File

@ -59,6 +59,17 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
resolve(&Config.terms/3) resolve(&Config.terms/3)
end 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 field(:privacy, :privacy, description: "The instance's privacy policy") do
arg(:locale, :string, arg(:locale, :string,
default_value: "en", default_value: "en",

View File

@ -9,7 +9,8 @@ defmodule Mobilizon.Admin do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.{Admin, Users} alias Mobilizon.{Admin, Users}
alias Mobilizon.Admin.ActionLog alias Mobilizon.Admin.ActionLog
alias Mobilizon.Admin.Setting alias Mobilizon.Admin.{Setting, SettingMedia}
alias Mobilizon.Medias.Media
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User alias Mobilizon.Users.User
@ -78,9 +79,47 @@ defmodule Mobilizon.Admin do
defp stringify_struct(struct), do: struct 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 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 end
@spec get_admin_setting_value(String.t(), String.t(), String.t() | nil) :: @spec get_admin_setting_value(String.t(), String.t(), String.t() | nil) ::
@ -119,21 +158,40 @@ defmodule Mobilizon.Admin do
end end
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} @spec save_settings(String.t(), map()) :: {:ok, any} | {:error, any}
def save_settings(group, args) do def save_settings(group, args) do
{medias, values} = Map.split(args, [:instance_logo, :instance_favicon, :default_picture])
Multi.new() Multi.new()
|> do_save_setting(group, args) |> do_save_media_setting(group, medias)
|> do_save_value_setting(group, values)
|> Repo.transaction() |> Repo.transaction()
end end
def clear_settings(group) do @spec do_save_value_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t()
Setting |> where([s], s.group == ^group) |> Repo.delete_all() defp do_save_value_setting(transaction, _group, args) when args == %{}, do: transaction
end
@spec do_save_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t() defp do_save_value_setting(transaction, group, args) do
defp do_save_setting(transaction, _group, args) when args == %{}, do: transaction
defp do_save_setting(transaction, group, args) do
key = hd(Map.keys(args)) key = hd(Map.keys(args))
{val, rest} = Map.pop(args, key) {val, rest} = Map.pop(args, key)
@ -150,7 +208,40 @@ defmodule Mobilizon.Admin do
conflict_target: [:group, :name] 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 end
@spec convert_to_string(any()) :: String.t() @spec convert_to_string(any()) :: String.t()

View File

@ -4,6 +4,7 @@ defmodule Mobilizon.Admin.Setting do
""" """
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Ecto.Changeset
@required_attrs [:group, :name] @required_attrs [:group, :name]
@optional_attrs [:value] @optional_attrs [:value]
@ -32,3 +33,93 @@ defmodule Mobilizon.Admin.Setting do
|> unique_constraint(:group, name: :admin_settings_group_name_index) |> unique_constraint(:group, name: :admin_settings_group_name_index)
end end
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

View File

@ -4,7 +4,8 @@ defmodule Mobilizon.Config do
""" """
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Admin.Setting alias Mobilizon.Admin
alias Mobilizon.Medias.Media
alias Mobilizon.Service.GitStatus alias Mobilizon.Service.GitStatus
require Logger require Logger
import Mobilizon.Service.Export.Participants.Common, only: [enabled_formats: 0] import Mobilizon.Service.Export.Participants.Common, only: [enabled_formats: 0]
@ -29,56 +30,18 @@ defmodule Mobilizon.Config do
@spec instance_config :: mobilizon_config @spec instance_config :: mobilizon_config
def instance_config, do: Application.get_env(:mobilizon, :instance) 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() @spec config_cache :: map()
def config_cache do def config_cache do
case Cachex.fetch(:config, :all_db_config, fn _key -> case Cachex.fetch(
value = :config,
Enum.reduce( :all_db_config,
Mobilizon.Admin.get_all_admin_settings(), fn _key -> {:commit, Admin.get_all_admin_settings()} end
%{}, ) do
&arrange_values/2
)
{:commit, value}
end) do
{status, value} when status in [:ok, :commit] -> value {status, value} when status in [:ok, :commit] -> value
_err -> %{} _err -> %{}
end end
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() @spec config_cached_value(String.t(), String.t(), String.t()) :: any()
def config_cached_value(group, name, fallback \\ nil) do def config_cached_value(group, name, fallback \\ nil) do
config_cache() config_cache()
@ -115,10 +78,23 @@ defmodule Mobilizon.Config do
@spec instance_slogan :: String.t() | nil @spec instance_slogan :: String.t() | nil
def instance_slogan, do: config_cached_value("instance", "instance_slogan") 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 @spec contact :: String.t() | nil
def contact do def contact, do: config_cached_value("instance", "contact")
config_cached_value("instance", "contact")
end
@spec instance_terms(String.t()) :: String.t() @spec instance_terms(String.t()) :: String.t()
def instance_terms(locale \\ "en") do def instance_terms(locale \\ "en") do
@ -470,6 +446,9 @@ defmodule Mobilizon.Config do
instance_slogan: instance_slogan(), instance_slogan: instance_slogan(),
registrations_open: instance_registrations_open?(), registrations_open: instance_registrations_open?(),
contact: contact(), contact: contact(),
primary_color: primary_color(),
secondary_color: secondary_color(),
instance_logo: instance_logo(),
instance_terms: instance_terms(), instance_terms: instance_terms(),
instance_terms_type: instance_terms_type(), instance_terms_type: instance_terms_type(),
instance_terms_url: instance_terms_url(), instance_terms_url: instance_terms_url(),

View File

@ -185,7 +185,8 @@ defmodule Mobilizon.Medias do
[from: "events_medias", param: "media_id"], [from: "events_medias", param: "media_id"],
[from: "posts", param: "picture_id"], [from: "posts", param: "picture_id"],
[from: "posts_medias", param: "media_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] -> |> Enum.map_join(" UNION ", fn [from: from, param: param] ->
"SELECT 1 FROM #{from} WHERE #{from}.#{param} = m0.id" "SELECT 1 FROM #{from} WHERE #{from}.#{param} = m0.id"

View File

@ -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

View File

@ -18,8 +18,7 @@ defmodule Mobilizon.Web do
""" """
def static_paths, def static_paths,
do: do: ~w(index.html service-worker.js css fonts img js robots.txt assets)
~w(index.html manifest.json manifest.webmanifest service-worker.js css fonts img js favicon.ico robots.txt assets)
def controller do def controller do
quote do quote do

View File

@ -113,6 +113,12 @@ defmodule Mobilizon.Web.Router do
get("/nodeinfo/:version", NodeInfoController, :nodeinfo) get("/nodeinfo/:version", NodeInfoController, :nodeinfo)
end 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 scope "/", Mobilizon.Web do
pipe_through(:activity_pub_and_html) pipe_through(:activity_pub_and_html)
pipe_through(:activity_pub_signature) pipe_through(:activity_pub_signature)

View File

@ -4,15 +4,16 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png" sizes="152x152" /> <link rel="apple-touch-icon" href={favicon_url()} sizes={favicon_sizes()} />
<link rel="icon" href={favicon_url()} sizes={favicon_sizes()} />
<link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color={theme_color()} /> <link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color={theme_color()} />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content={theme_color()} /> <meta name="theme-color" content={theme_color()} />
<script> <script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
} else { } else {
document.documentElement.classList.remove('dark') document.documentElement.classList.remove('dark')
} }
</script> </script>
<%= if root?(assigns) do %> <%= if root?(assigns) do %>
@ -24,6 +25,7 @@
<%= Vite.vite_client() %> <%= Vite.vite_client() %>
<%= Vite.vite_snippet("src/main.ts") %> <%= Vite.vite_snippet("src/main.ts") %>
</head> </head>
<body> <body>
<noscript> <noscript>
<strong> <strong>

View File

@ -6,6 +6,7 @@ defmodule Mobilizon.Web.PageView do
use Mobilizon.Web, :view use Mobilizon.Web, :view
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Config
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post alias Mobilizon.Posts.Post
@ -91,4 +92,27 @@ defmodule Mobilizon.Web.PageView do
def root?(assigns) do def root?(assigns) do
assigns |> Map.get(:conn, %{request_path: "/"}) |> Map.get(:request_path, "/") == "/" assigns |> Map.get(:conn, %{request_path: "/"}) |> Map.get(:request_path, "/") == "/"
end end
defp favicon do
case Config.instance_favicon() do
%{file: %{url: url}, metadata: metadata} ->
%{
src: url,
sizes:
case metadata do
%{width: width} -> "#{width}x#{width}"
_ -> "any"
end
}
_ ->
%{
src: "/img/icons/apple-touch-icon-152x152.png",
sizes: "152x152"
}
end
end
def favicon_url, do: Map.get(favicon(), :src)
def favicon_sizes, do: Map.get(favicon(), :sizes)
end end

View File

@ -0,0 +1,15 @@
defmodule Mobilizon.Storage.Repo.Migrations.CreateAdminSettingsMedias do
use Ecto.Migration
def change do
create table(:admin_settings_medias) do
add(:group, :string)
add(:name, :string)
add(:media_id, references(:medias, on_delete: :delete_all))
timestamps()
end
create(unique_index(:admin_settings_medias, [:group, :name]))
end
end

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -169,6 +169,18 @@ type Config {
"The instance's slogan" "The instance's slogan"
slogan: String slogan: String
"The instance's logo"
instanceLogo: Media
"The default picture"
defaultPicture: Media
"The instance's primary color"
primaryColor: String
"The instance's secondary color"
secondaryColor: String
"The instance's contact details" "The instance's contact details"
contact: String contact: String
@ -1878,6 +1890,15 @@ type RootMutationType {
"The instance's contact details" "The instance's contact details"
contact: String contact: String
"The instance's logo"
instanceLogo: MediaInput
"The instance's favicon"
instanceFavicon: MediaInput
"The default picture"
defaultPicture: MediaInput
"The instance's terms body text" "The instance's terms body text"
instanceTerms: String instanceTerms: String
@ -3698,6 +3719,15 @@ type AdminSettings {
"The instance's contact details" "The instance's contact details"
contact: String contact: String
"The instance's logo"
instanceLogo: Media
"The instance's favicon"
instanceFavicon: Media
"The default picture"
defaultPicture: Media
"The instance's terms body text" "The instance's terms body text"
instanceTerms: String instanceTerms: String

View File

@ -108,7 +108,7 @@ import { IAnalyticsConfig, IConfig } from "@/types/config.model";
import { computed, defineAsyncComponent, ref } from "vue"; import { computed, defineAsyncComponent, ref } from "vue";
import { useQuery, useQueryLoading } from "@vue/apollo-composable"; import { useQuery, useQueryLoading } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useAnalytics } from "@/composition/apollo/config"; import { useAnalytics } from "@/composition/apollo/config";
import { INSTANCE_NAME } from "@/graphql/config"; import { INSTANCE_NAME } from "@/graphql/config";
const SentryFeedback = defineAsyncComponent( const SentryFeedback = defineAsyncComponent(

View File

@ -16,7 +16,7 @@ import { useGroup } from "@/composition/apollo/group";
import { displayName } from "@/types/actor"; import { displayName } from "@/types/actor";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const props = defineProps<{ const props = defineProps<{
preferredUsername: string; preferredUsername: string;

View File

@ -11,8 +11,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue"; import { computed } from "vue";
import { IMedia } from "@/types/media.model"; import { IMedia } from "@/types/media.model";
import { useDefaultPicture } from "@/composition/apollo/config";
import LazyImage from "../Image/LazyImage.vue"; import LazyImage from "../Image/LazyImage.vue";
const { defaultPicture } = useDefaultPicture();
const DEFAULT_CARD_URL = "/img/mobilizon_default_card.png"; const DEFAULT_CARD_URL = "/img/mobilizon_default_card.png";
const DEFAULT_BLURHASH = "MCHKI4El-P-U}+={R-WWoes,Iu-P=?R,xD"; const DEFAULT_BLURHASH = "MCHKI4El-P-U}+={R-WWoes,Iu-P=?R,xD";
const DEFAULT_WIDTH = 630; const DEFAULT_WIDTH = 630;
@ -38,6 +41,9 @@ const props = withDefaults(
const pictureOrDefault = computed(() => { const pictureOrDefault = computed(() => {
if (props.picture === null) { if (props.picture === null) {
if (defaultPicture?.value?.url) {
return defaultPicture.value;
}
return DEFAULT_PICTURE; return DEFAULT_PICTURE;
} }
return { return {

View File

@ -1,6 +1,7 @@
<template> <template>
<svg <svg
class="bg-white dark:bg-zinc-900 dark:fill-white" v-if="!instanceLogoUrl"
class="bg-white dark:bg-zinc-900 dark:fill-white max-h-12"
:class="{ 'bg-gray-900': invert }" :class="{ 'bg-gray-900': invert }"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 248.16 46.78" viewBox="0 0 248.16 46.78"
@ -30,9 +31,14 @@
/> />
</g> </g>
</svg> </svg>
<img v-else alt="" class="max-h-12 w-auto" :src="instanceLogoUrl" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useInstanceLogoUrl } from "@/composition/apollo/config";
const { instanceLogoUrl } = useInstanceLogoUrl();
withDefaults( withDefaults(
defineProps<{ defineProps<{
invert?: boolean; invert?: boolean;

View File

@ -3,9 +3,7 @@
class="bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-zinc-900" class="bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-zinc-900"
id="navbar" id="navbar"
> >
<div <div class="container mx-auto flex flex-wrap items-center gap-2 sm:gap-4">
class="container mx-auto flex flex-wrap items-center mx-auto gap-2 sm:gap-4"
>
<router-link <router-link
:to="{ name: RouteName.HOME }" :to="{ name: RouteName.HOME }"
class="flex items-center" class="flex items-center"

View File

@ -90,7 +90,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, inject, ref } from "vue"; import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";

View File

@ -45,7 +45,7 @@ import { LEAVE_EVENT } from "../../graphql/event";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { IActor } from "@/types/actor"; import { IActor } from "@/types/actor";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { useAnonymousActorId } from "@/composition/apollo/config"; import { useAnonymousActorId } from "@/composition/apollo/config";

View File

@ -70,7 +70,7 @@ import { CONFIRM_PARTICIPATION } from "../../graphql/event";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });

View File

@ -9,7 +9,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import RedirectWithAccount from "@/components/Utils/RedirectWithAccount.vue"; import RedirectWithAccount from "@/components/Utils/RedirectWithAccount.vue";
import { useFetchEvent } from "@/composition/apollo/event"; import { useFetchEvent } from "@/composition/apollo/event";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";

View File

@ -146,7 +146,7 @@ import { useFetchEventBasic } from "@/composition/apollo/event";
import { useAnonymousActorId } from "@/composition/apollo/config"; import { useAnonymousActorId } from "@/composition/apollo/config";
import { computed, reactive, ref } from "vue"; import { computed, reactive, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
const error = ref<boolean | string>(false); const error = ref<boolean | string>(false);

View File

@ -99,7 +99,7 @@ import { useFetchEvent } from "@/composition/apollo/event";
import { useAnonymousParticipationConfig } from "@/composition/apollo/config"; import { useAnonymousParticipationConfig } from "@/composition/apollo/config";
import { computed } from "vue"; import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
const props = defineProps<{ uuid: string }>(); const props = defineProps<{ uuid: string }>();

View File

@ -4,11 +4,14 @@ import {
ANONYMOUS_ACTOR_ID, ANONYMOUS_ACTOR_ID,
ANONYMOUS_PARTICIPATION_CONFIG, ANONYMOUS_PARTICIPATION_CONFIG,
ANONYMOUS_REPORTS_CONFIG, ANONYMOUS_REPORTS_CONFIG,
DEFAULT_PICTURE,
DEMO_MODE, DEMO_MODE,
EVENT_CATEGORIES, EVENT_CATEGORIES,
EVENT_PARTICIPANTS, EVENT_PARTICIPANTS,
FEATURES, FEATURES,
GEOCODING_AUTOCOMPLETE, GEOCODING_AUTOCOMPLETE,
COLORS,
INSTANCE_LOGO,
LOCATION, LOCATION,
MAPS_TILES, MAPS_TILES,
REGISTRATIONS, REGISTRATIONS,
@ -76,6 +79,36 @@ export function useInstanceName() {
return { instanceName, error, loading }; return { instanceName, error, loading };
} }
export function useInstanceLogoUrl() {
const { result, error, loading } = useQuery<{
config: Pick<IConfig, "instanceLogo">;
}>(INSTANCE_LOGO);
const instanceLogoUrl = computed(
() => result.value?.config?.instanceLogo?.url
);
return { instanceLogoUrl, error, loading };
}
export function useColors() {
const { result, error, loading } = useQuery<{
config: Pick<IConfig, "primaryColor" | "secondaryColor">;
}>(COLORS);
const primaryColor = computed(() => result.value?.config?.primaryColor);
const secondaryColor = computed(() => result.value?.config?.secondaryColor);
return { primaryColor, secondaryColor, error, loading };
}
export function useDefaultPicture() {
const { result, error, loading } = useQuery<{
config: Pick<IConfig, "defaultPicture">;
}>(DEFAULT_PICTURE);
const defaultPicture = computed(() => result.value?.config?.defaultPicture);
return { defaultPicture, error, loading };
}
export function useAnonymousActorId() { export function useAnonymousActorId() {
const { result, error, loading } = useQuery<{ const { result, error, loading } = useQuery<{
config: Pick<IConfig, "anonymous">; config: Pick<IConfig, "anonymous">;

View File

@ -4,6 +4,12 @@ export const useHost = (): string => {
return window.location.hostname; return window.location.hostname;
}; };
export const useDefaultMaxSize = (): number | undefined => {
const { uploadLimits } = useUploadLimits();
return uploadLimits.value?.default;
};
export const useAvatarMaxSize = (): number | undefined => { export const useAvatarMaxSize = (): number | undefined => {
const { uploadLimits } = useUploadLimits(); const { uploadLimits } = useUploadLimits();

View File

@ -195,6 +195,23 @@ export const ADMIN_SETTINGS_FRAGMENT = gql`
instanceLongDescription instanceLongDescription
instanceSlogan instanceSlogan
contact contact
instanceLogo {
id
url
name
}
instanceFavicon {
id
url
name
}
defaultPicture {
id
url
name
}
primaryColor
secondaryColor
instanceTerms instanceTerms
instanceTermsType instanceTermsType
instanceTermsUrl instanceTermsUrl
@ -223,6 +240,11 @@ export const SAVE_ADMIN_SETTINGS = gql`
$instanceLongDescription: String $instanceLongDescription: String
$instanceSlogan: String $instanceSlogan: String
$contact: String $contact: String
$instanceLogo: MediaInput
$instanceFavicon: MediaInput
$defaultPicture: MediaInput
$primaryColor: String
$secondaryColor: String
$instanceTerms: String $instanceTerms: String
$instanceTermsType: InstanceTermsType $instanceTermsType: InstanceTermsType
$instanceTermsUrl: String $instanceTermsUrl: String
@ -239,6 +261,11 @@ export const SAVE_ADMIN_SETTINGS = gql`
instanceLongDescription: $instanceLongDescription instanceLongDescription: $instanceLongDescription
instanceSlogan: $instanceSlogan instanceSlogan: $instanceSlogan
contact: $contact contact: $contact
instanceLogo: $instanceLogo
instanceFavicon: $instanceFavicon
defaultPicture: $defaultPicture
primaryColor: $primaryColor
secondaryColor: $secondaryColor
instanceTerms: $instanceTerms instanceTerms: $instanceTerms
instanceTermsType: $instanceTermsType instanceTermsType: $instanceTermsType
instanceTermsUrl: $instanceTermsUrl instanceTermsUrl: $instanceTermsUrl

View File

@ -12,6 +12,21 @@ export const CONFIG = gql`
demoMode demoMode
countryCode countryCode
languages languages
primaryColor
secondaryColor
instanceLogo {
url
}
defaultPicture {
id
url
name
metadata {
width
height
blurhash
}
}
eventCategories { eventCategories {
id id
label label
@ -454,6 +469,42 @@ export const SEARCH_CONFIG = gql`
} }
`; `;
export const INSTANCE_LOGO = gql`
query InstanceLogo {
config {
instanceLogo {
url
}
}
}
`;
export const COLORS = gql`
query Colors {
config {
primaryColor
secondaryColor
}
}
`;
export const DEFAULT_PICTURE = gql`
query DefaultPicture {
config {
defaultPicture {
id
url
name
metadata {
width
height
blurhash
}
}
}
}
`;
export const REGISTRATIONS = gql` export const REGISTRATIONS = gql`
query Registrations { query Registrations {
config { config {

View File

@ -416,6 +416,14 @@
"No one is participating|One person participating|{going} people participating": "No one is participating|One person participating|{going} people participating", "No one is participating|One person participating|{going} people participating": "No one is participating|One person participating|{going} people participating",
"Date and time": "Date and time", "Date and time": "Date and time",
"Location": "Location", "Location": "Location",
"Logo": "Logo",
"Logo of the instance. Defaults to the upstream Mobilizon logo.": "Logo of the instance. Defaults to the upstream Mobilizon logo.",
"Favicon": "Favicon",
"Browser tab icon and PWA icon of the instance. Defaults to the upstream Mobilizon icon.": "Browser tab icon and PWA icon of the instance. Defaults to the upstream Mobilizon icon.",
"Default Picture": "Default Picture",
"Default picture when an event or group doesn't have one.": "Default picture when an event or group doesn't have one.",
"Primary Color": "Primary Color",
"Secondary Color": "Secondary Color",
"No resources selected": "No resources selected|One resources selected|{count} resources selected", "No resources selected": "No resources selected|One resources selected|{count} resources selected",
"You have been invited by {invitedBy} to the following group:": "You have been invited by {invitedBy} to the following group:", "You have been invited by {invitedBy} to the following group:": "You have been invited by {invitedBy} to the following group:",
"Accept": "Accept", "Accept": "Accept",
@ -1645,4 +1653,4 @@
"Domain or instance name": "Domain or instance name", "Domain or instance name": "Domain or instance name",
"You need to enter a text": "You need to enter a text", "You need to enter a text": "You need to enter a text",
"Error while adding tag: {error}": "Error while adding tag: {error}" "Error while adding tag: {error}": "Error while adding tag: {error}"
} }

View File

@ -627,6 +627,14 @@
"Local times ({timezone})": "Heures locales ({timezone})", "Local times ({timezone})": "Heures locales ({timezone})",
"Locality": "Commune", "Locality": "Commune",
"Location": "Lieu", "Location": "Lieu",
"Logo": "Logo",
"Logo of the instance. Defaults to the upstream Mobilizon logo.": "Logo de l'instance.",
"Favicon": "Favicon",
"Browser tab icon and PWA icon of the instance. Defaults to the upstream Mobilizon icon.": "Icône de l'onglet du navigateur et de la progressive web app.",
"Default Picture": "Image par défaut",
"Default picture when an event or group doesn't have one.": "Image par défaut quand un évènement ou groupe n'en a pas.",
"Primary Color": "Couleur primaire",
"Secondary Color": "Couleur secondaire",
"Log in": "Se connecter", "Log in": "Se connecter",
"Log out": "Se déconnecter", "Log out": "Se déconnecter",
"Login": "Se connecter", "Login": "Se connecter",

View File

@ -57,6 +57,21 @@ apolloClient
}) })
.then(({ data: configData }) => { .then(({ data: configData }) => {
instanceName.value = configData.config?.name; instanceName.value = configData.config?.name;
const primaryColor = configData.config?.primaryColor;
if (primaryColor) {
document.documentElement.style.setProperty(
"--custom-primary",
primaryColor
);
}
const secondaryColor = configData.config?.secondaryColor;
if (secondaryColor) {
document.documentElement.style.setProperty(
"--custom-secondary",
secondaryColor
);
}
}); });
const head = createHead(); const head = createHead();

View File

@ -1,4 +1,5 @@
import type { IEvent } from "@/types/event.model"; import type { IEvent } from "@/types/event.model";
import type { IMedia } from "@/types/media.model";
import type { IGroup } from "./actor"; import type { IGroup } from "./actor";
import { InstancePrivacyType, InstanceTermsType } from "./enums"; import { InstancePrivacyType, InstanceTermsType } from "./enums";
@ -25,6 +26,10 @@ export interface IAdminSettings {
instanceSlogan: string; instanceSlogan: string;
instanceLongDescription: string; instanceLongDescription: string;
contact: string; contact: string;
instanceLogo: IMedia | null;
defaultPicture: IMedia | null;
primaryColor: string;
secondaryColor: string;
instanceTerms: string; instanceTerms: string;
instanceTermsType: InstanceTermsType; instanceTermsType: InstanceTermsType;
instanceTermsUrl: string | null; instanceTermsUrl: string | null;

View File

@ -37,6 +37,10 @@ export interface IConfig {
longDescription: string; longDescription: string;
contact: string; contact: string;
slogan: string; slogan: string;
instanceLogo: { url: string };
defaultPicture: { url: string };
primaryColor: string;
secondaryColor: string;
registrationsOpen: boolean; registrationsOpen: boolean;
registrationsAllowlist: boolean; registrationsAllowlist: boolean;

View File

@ -1,3 +1,5 @@
import type { Ref } from "vue";
export interface IMedia { export interface IMedia {
id: string; id: string;
url: string; url: string;
@ -21,3 +23,9 @@ export interface IMediaMetadata {
height?: number; height?: number;
blurhash?: string; blurhash?: string;
} }
export interface IModifiableMedia {
file: Ref<File | null>;
firstHash: string | null;
hash: string | null;
}

22
src/utils/head.ts Normal file
View File

@ -0,0 +1,22 @@
import { computed } from "vue";
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
import { useHead as unHead } from "@unhead/vue";
import { apolloClient } from "@/vue-apollo";
import { IConfig } from "@/types/config.model";
import { ABOUT } from "@/graphql/config";
const { result } = provideApolloClient(apolloClient)(() =>
useQuery<{ config: Pick<IConfig, "name"> }>(ABOUT)
);
const instanceName = computed(() => result.value?.config?.name);
export function useHead(args: any) {
return unHead({
...args,
title: computed(() =>
args?.title?.value
? `${args.title.value} - ${instanceName.value}`
: instanceName.value
),
});
}

View File

@ -1,4 +1,5 @@
import { IMedia } from "@/types/media.model"; import { IMedia, IModifiableMedia } from "@/types/media.model";
import { ref, watch } from "vue";
export async function buildFileFromIMedia( export async function buildFileFromIMedia(
obj: IMedia | null | undefined obj: IMedia | null | undefined
@ -29,18 +30,83 @@ export function buildFileVariable(
}; };
} }
export function readFileAsync( export function readFileAsync(file: File): Promise<ArrayBuffer | null> {
file: File
): Promise<string | ArrayBuffer | null> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
resolve(reader.result); resolve(reader.result as ArrayBuffer);
}; };
reader.onerror = reject; reader.onerror = reject;
reader.readAsBinaryString(file); reader.readAsArrayBuffer(file);
}); });
} }
export async function fileHash(file: File): Promise<string | null> {
const data = await readFileAsync(file);
if (data === null) return null;
const hash = await crypto.subtle.digest("SHA-1", data);
const b64Hash = btoa(
Array.from(new Uint8Array(hash))
.map((b) => String.fromCharCode(b))
.join("")
);
return b64Hash;
}
export function initWrappedMedia(): IModifiableMedia {
return {
file: ref<File | null>(null),
firstHash: null,
hash: null,
};
}
export async function loadWrappedMedia(
modifiableMedia: IModifiableMedia,
media: IMedia | null
) {
watch(modifiableMedia.file, async () => {
if (modifiableMedia.file.value) {
modifiableMedia.hash = await fileHash(modifiableMedia.file.value);
} else {
modifiableMedia.hash = null;
}
});
try {
modifiableMedia.file.value = await buildFileFromIMedia(media);
} catch (e) {
console.error("catched error while building media", e);
}
if (modifiableMedia.file.value) {
modifiableMedia.firstHash = await fileHash(modifiableMedia.file.value);
}
}
export function asMediaInput(
mmedia: IModifiableMedia,
name: string,
fallbackId: number
): any {
const ret = {
[name]: {},
};
if (mmedia.file.value) {
if (mmedia.firstHash != mmedia.hash) {
ret[name] = {
media: {
name: mmedia.file.value?.name,
alt: "",
file: mmedia.file.value,
},
};
} else {
ret[name] = {
mediaId: fallbackId,
};
}
}
return ret;
}

View File

@ -123,7 +123,7 @@ import { IStatistics } from "../../types/statistics.model";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT); const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);

View File

@ -71,7 +71,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { ABOUT } from "../../graphql/config"; import { ABOUT } from "../../graphql/config";

View File

@ -14,7 +14,7 @@ import { PRIVACY } from "@/graphql/config";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { InstancePrivacyType } from "@/types/enums"; import { InstancePrivacyType } from "@/types/enums";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, watch } from "vue"; import { computed, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";

View File

@ -14,7 +14,7 @@
import { RULES } from "@/graphql/config"; import { RULES } from "@/graphql/config";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";

View File

@ -15,7 +15,7 @@ import { TERMS } from "@/graphql/config";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { InstanceTermsType } from "@/types/enums"; import { InstanceTermsType } from "@/types/enums";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, watch } from "vue"; import { computed, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";

View File

@ -113,7 +113,7 @@ import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue"; import { computed } from "vue";
import { useCurrentUserClient } from "@/composition/apollo/user"; import { useCurrentUserClient } from "@/composition/apollo/user";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const { currentUser } = useCurrentUserClient(); const { currentUser } = useCurrentUserClient();

View File

@ -140,7 +140,7 @@ import { useRouter } from "vue-router";
import { registerAccount } from "@/composition/apollo/user"; import { registerAccount } from "@/composition/apollo/user";
import { convertToUsername } from "@/utils/username"; import { convertToUsername } from "@/utils/username";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { getValueFromMeta } from "@/utils/html"; import { getValueFromMeta } from "@/utils/html";
const props = withDefaults( const props = withDefaults(

View File

@ -224,7 +224,7 @@ import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLErrors } from "@/types/errors.model"; import { AbsintheGraphQLErrors } from "@/types/errors.model";
import { ICurrentUser } from "@/types/current-user.model"; import { ICurrentUser } from "@/types/current-user.model";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const router = useRouter(); const router = useRouter();

View File

@ -336,7 +336,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { ApolloCache, FetchResult } from "@apollo/client/core"; import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject } from "vue"; import { computed, inject } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { import {

View File

@ -319,7 +319,7 @@ import { MemberRole } from "@/types/enums";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { import {

View File

@ -327,7 +327,7 @@ import { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { ILanguage } from "@/types/admin.model"; import { ILanguage } from "@/types/admin.model";
import { computed, inject, reactive, ref, watch } from "vue"; import { computed, inject, reactive, ref, watch } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { formatDateTimeString } from "@/filters/datetime"; import { formatDateTimeString } from "@/filters/datetime";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";

View File

@ -90,7 +90,7 @@ import RouteName from "@/router/name";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import NumberDashboardTile from "@/components/Dashboard/NumberDashboardTile.vue"; import NumberDashboardTile from "@/components/Dashboard/NumberDashboardTile.vue";
import LinkedNumberDashboardTile from "@/components/Dashboard/LinkedNumberDashboardTile.vue"; import LinkedNumberDashboardTile from "@/components/Dashboard/LinkedNumberDashboardTile.vue";
import { InstanceFilterFollowStatus } from "@/types/enums"; import { InstanceFilterFollowStatus } from "@/types/enums";

View File

@ -119,7 +119,7 @@ import {
useRouteQuery, useRouteQuery,
} from "vue-use-route-query"; } from "vue-use-route-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { IGroup } from "@/types/actor"; import { IGroup } from "@/types/actor";

View File

@ -235,7 +235,7 @@ import {
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref, watch } from "vue"; import { computed, inject, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue"; import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import MastodonLogo from "@/components/Share/MastodonLogo.vue"; import MastodonLogo from "@/components/Share/MastodonLogo.vue";

View File

@ -103,7 +103,7 @@ import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { computed } from "vue"; import { computed } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { import {
useRouteQuery, useRouteQuery,
booleanTransformer, booleanTransformer,

View File

@ -58,6 +58,68 @@
</small> </small>
<o-input v-model="settingsToWrite.contact" id="instance-contact" /> <o-input v-model="settingsToWrite.contact" id="instance-contact" />
</div> </div>
<label class="field flex flex-col">
<p>{{ t("Logo") }}</p>
<small>
{{
t(
"Logo of the instance. Defaults to the upstream Mobilizon logo."
)
}}
</small>
<picture-upload
v-model:modelValue="instanceLogoFile"
:defaultImage="settingsToWrite.instanceLogo"
:textFallback="t('Logo')"
:maxSize="maxSize"
/>
</label>
<label class="field flex flex-col">
<p>{{ t("Favicon") }}</p>
<small>
{{
t(
"Browser tab icon and PWA icon of the instance. Defaults to the upstream Mobilizon icon."
)
}}
</small>
<picture-upload
v-model:modelValue="instanceFaviconFile"
:defaultImage="settingsToWrite.instanceFavicon"
:textFallback="t('Favicon')"
:maxSize="maxSize"
/>
</label>
<label class="field flex flex-col">
<p>{{ t("Default Picture") }}</p>
<small>
{{ t("Default picture when an event or group doesn't have one.") }}
</small>
<picture-upload
v-model:modelValue="defaultPictureFile"
:defaultImage="settingsToWrite.defaultPicture"
:textFallback="t('Default Picture')"
:maxSize="maxSize"
/>
</label>
<div class="field flex flex-col">
<label class="" for="primary-color">{{ t("Primary Color") }}</label>
<o-input
type="color"
v-model="settingsToWrite.primaryColor"
id="primary-color"
/>
</div>
<div class="field flex flex-col">
<label class="" for="secondary-color">{{
t("Secondary Color")
}}</label>
<o-input
type="color"
v-model="settingsToWrite.secondaryColor"
id="secondary-color"
/>
</div>
<o-field :label="t('Allow registrations')"> <o-field :label="t('Allow registrations')">
<o-switch v-model="settingsToWrite.registrationsOpen"> <o-switch v-model="settingsToWrite.registrationsOpen">
<p <p
@ -389,15 +451,29 @@ import RouteName from "@/router/name";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { ref, computed, watch, inject } from "vue"; import { ref, computed, watch, inject } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import type { Notifier } from "@/plugins/notifier"; import type { Notifier } from "@/plugins/notifier";
// Media upload related
import PictureUpload from "@/components/PictureUpload.vue";
import {
initWrappedMedia,
loadWrappedMedia,
asMediaInput,
} from "@/utils/image";
import { useDefaultMaxSize } from "@/composition/config";
const defaultAdminSettings: IAdminSettings = { const defaultAdminSettings: IAdminSettings = {
instanceName: "", instanceName: "",
instanceDescription: "", instanceDescription: "",
instanceSlogan: "", instanceSlogan: "",
instanceLongDescription: "", instanceLongDescription: "",
contact: "", contact: "",
instanceLogo: null,
instanceFavicon: null,
defaultPicture: null,
primaryColor: "",
secondaryColor: "",
instanceTerms: "", instanceTerms: "",
instanceTermsType: InstanceTermsType.DEFAULT, instanceTermsType: InstanceTermsType.DEFAULT,
instanceTermsUrl: null, instanceTermsUrl: null,
@ -409,12 +485,30 @@ const defaultAdminSettings: IAdminSettings = {
instanceLanguages: [], instanceLanguages: [],
}; };
const { result: adminSettingsResult } = useQuery<{ const { onResult: onAdminSettingsResult } = useQuery<{
adminSettings: IAdminSettings; adminSettings: IAdminSettings;
}>(ADMIN_SETTINGS); }>(ADMIN_SETTINGS);
const adminSettings = computed(
() => adminSettingsResult.value?.adminSettings ?? defaultAdminSettings const adminSettings = ref<IAdminSettings>();
);
onAdminSettingsResult(async ({ data }) => {
if (!data) return;
adminSettings.value =
{
...data.adminSettings,
} ?? defaultAdminSettings;
loadWrappedMedia(instanceLogo, adminSettings.value.instanceLogo);
loadWrappedMedia(instanceFavicon, adminSettings.value.instanceFavicon);
loadWrappedMedia(defaultPicture, adminSettings.value.defaultPicture);
});
const instanceLogo = initWrappedMedia();
const { file: instanceLogoFile } = instanceLogo;
const instanceFavicon = initWrappedMedia();
const { file: instanceFaviconFile } = instanceFavicon;
const defaultPicture = initWrappedMedia();
const { file: defaultPictureFile } = defaultPicture;
const { result: languageResult } = useQuery<{ languages: ILanguage[] }>( const { result: languageResult } = useQuery<{ languages: ILanguage[] }>(
LANGUAGES LANGUAGES
@ -463,6 +557,9 @@ const {
} = useMutation(SAVE_ADMIN_SETTINGS); } = useMutation(SAVE_ADMIN_SETTINGS);
saveAdminSettingsDone(() => { saveAdminSettingsDone(() => {
instanceLogo.firstHash = instanceLogo.hash;
instanceFavicon.firstHash = instanceFavicon.hash;
defaultPicture.firstHash = defaultPicture.hash;
notifier?.success(t("Admin settings successfully saved.") as string); notifier?.success(t("Admin settings successfully saved.") as string);
}); });
@ -472,11 +569,29 @@ saveAdminSettingsError((e) => {
}); });
const updateSettings = async (): Promise<void> => { const updateSettings = async (): Promise<void> => {
const variables = { ...settingsToWrite.value }; const variables = {
console.debug("updating settings with variables", variables); ...settingsToWrite.value,
...asMediaInput(
instanceLogo,
"instanceLogo",
adminSettings.value?.instanceLogo?.id
),
...asMediaInput(
instanceFavicon,
"instanceFavicon",
adminSettings.value?.instanceFavicon?.id
),
...asMediaInput(
defaultPicture,
"defaultPicture",
adminSettings.value?.defaultPicture?.id
),
};
saveAdminSettings(variables); saveAdminSettings(variables);
}; };
const maxSize = useDefaultMaxSize();
const getFilteredLanguages = (text: string): void => { const getFilteredLanguages = (text: string): void => {
filteredLanguages.value = languages.value filteredLanguages.value = languages.value
? languages.value ? languages.value

View File

@ -110,7 +110,7 @@ import { useQuery } from "@vue/apollo-composable";
import { ILanguage } from "@/types/admin.model"; import { ILanguage } from "@/types/admin.model";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { formatDateTimeString } from "@/filters/datetime"; import { formatDateTimeString } from "@/filters/datetime";

View File

@ -103,7 +103,7 @@ import {
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useEventCategories } from "@/composition/apollo/config"; import { useEventCategories } from "@/composition/apollo/config";
import EmptyContent from "@/components/Utils/EmptyContent.vue"; import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });

View File

@ -54,7 +54,7 @@ import {
import { PROFILE_CONVERSATIONS } from "@/graphql/event"; import { PROFILE_CONVERSATIONS } from "@/graphql/event";
import ConversationListItem from "../../components/Conversations/ConversationListItem.vue"; import ConversationListItem from "../../components/Conversations/ConversationListItem.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue"; import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { IPerson } from "@/types/actor"; import { IPerson } from "@/types/actor";
import { useOruga } from "@oruga-ui/oruga-next"; import { useOruga } from "@oruga-ui/oruga-next";
import { arrayTransformer } from "@/utils/route"; import { arrayTransformer } from "@/utils/route";

View File

@ -189,7 +189,7 @@ import {
onMounted, onMounted,
onUnmounted, onUnmounted,
} from "vue"; } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useCurrentActorClient } from "../../composition/apollo/actor"; import { useCurrentActorClient } from "../../composition/apollo/actor";
import { AbsintheGraphQLError } from "../../types/errors.model"; import { AbsintheGraphQLError } from "../../types/errors.model";

View File

@ -70,7 +70,7 @@ import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { IDiscussion } from "@/types/discussions"; import { IDiscussion } from "@/types/discussions";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLError } from "@/types/errors.model"; import { AbsintheGraphQLError } from "@/types/errors.model";

View File

@ -172,7 +172,7 @@ import {
computed, computed,
inject, inject,
} from "vue"; } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { AbsintheGraphQLError } from "@/types/errors.model"; import { AbsintheGraphQLError } from "@/types/errors.model";

View File

@ -83,7 +83,7 @@ import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { usePersonStatusGroup } from "@/composition/apollo/actor"; import { usePersonStatusGroup } from "@/composition/apollo/actor";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouteQuery, integerTransformer } from "vue-use-route-query"; import { useRouteQuery, integerTransformer } from "vue-use-route-query";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
const page = useRouteQuery("page", 1, integerTransformer); const page = useRouteQuery("page", 1, integerTransformer);

View File

@ -12,7 +12,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ErrorCode } from "@/types/enums"; import { ErrorCode } from "@/types/enums";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouteQuery } from "vue-use-route-query"; import { useRouteQuery } from "vue-use-route-query";
import { computed } from "vue"; import { computed } from "vue";

View File

@ -24,7 +24,7 @@
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import EventConversations from "../../components/Conversations/EventConversations.vue"; import EventConversations from "../../components/Conversations/EventConversations.vue";
import NewPrivateMessage from "../../components/Participation/NewPrivateMessage.vue"; import NewPrivateMessage from "../../components/Participation/NewPrivateMessage.vue";
import { useFetchEvent } from "@/composition/apollo/event"; import { useFetchEvent } from "@/composition/apollo/event";

View File

@ -635,7 +635,7 @@ import {
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { Dialog } from "@/plugins/dialog"; import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useOruga } from "@oruga-ui/oruga-next"; import { useOruga } from "@oruga-ui/oruga-next";
import type { Locale } from "date-fns"; import type { Locale } from "date-fns";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";

View File

@ -326,7 +326,7 @@ import {
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLErrors } from "@/types/errors.model"; import { AbsintheGraphQLErrors } from "@/types/errors.model";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const IntegrationTwitch = defineAsyncComponent( const IntegrationTwitch = defineAsyncComponent(
() => import("@/components/Event/Integrations/TwitchIntegration.vue") () => import("@/components/Event/Integrations/TwitchIntegration.vue")

View File

@ -116,7 +116,7 @@ import {
useRouteQuery, useRouteQuery,
} from "vue-use-route-query"; } from "vue-use-route-query";
import { MemberRole } from "@/types/enums"; import { MemberRole } from "@/types/enums";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
const EVENTS_PAGE_LIMIT = 10; const EVENTS_PAGE_LIMIT = 10;

View File

@ -237,7 +237,7 @@ import {
import { Locale } from "date-fns"; import { Locale } from "date-fns";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRestrictions } from "@/composition/apollo/config"; import { useRestrictions } from "@/composition/apollo/config";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const EventParticipationCard = defineAsyncComponent( const EventParticipationCard = defineAsyncComponent(
() => import("@/components/Event/EventParticipationCard.vue") () => import("@/components/Event/EventParticipationCard.vue")

View File

@ -284,7 +284,7 @@ import Incognito from "vue-material-design-icons/Incognito.vue";
import EmptyContent from "@/components/Utils/EmptyContent.vue"; import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import Tag from "@/components/TagElement.vue"; import Tag from "@/components/TagElement.vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const PARTICIPANTS_PER_PAGE = 10; const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130; const MESSAGE_ELLIPSIS_LENGTH = 130;

View File

@ -231,7 +231,7 @@ import {
useHost, useHost,
} from "@/composition/config"; } from "@/composition/config";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { Openness, GroupVisibility } from "@/types/enums"; import { Openness, GroupVisibility } from "@/types/enums";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue"; import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";

View File

@ -126,7 +126,7 @@ import {
useRouteQuery, useRouteQuery,
} from "vue-use-route-query"; } from "vue-use-route-query";
import { computed, inject } from "vue"; import { computed, inject } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { usePersonStatusGroup } from "@/composition/apollo/actor"; import { usePersonStatusGroup } from "@/composition/apollo/actor";
import { MemberRole } from "@/types/enums"; import { MemberRole } from "@/types/enums";

View File

@ -250,7 +250,7 @@ import {
} from "@/graphql/member"; } from "@/graphql/member";
import { usernameWithDomain, displayName, IGroup } from "@/types/actor"; import { usernameWithDomain, displayName, IGroup } from "@/types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue"; import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref } from "vue"; import { computed, inject, ref } from "vue";

View File

@ -208,7 +208,7 @@ import { DELETE_GROUP } from "@/graphql/group";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Dialog } from "@/plugins/dialog"; import { Dialog } from "@/plugins/dialog";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
const Editor = defineAsyncComponent( const Editor = defineAsyncComponent(

View File

@ -706,7 +706,7 @@ import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.v
import Earth from "vue-material-design-icons/Earth.vue"; import Earth from "vue-material-design-icons/Earth.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useCreateReport } from "@/composition/apollo/report"; import { useCreateReport } from "@/composition/apollo/report";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import Discussions from "@/components/Group/Sections/DiscussionsSection.vue"; import Discussions from "@/components/Group/Sections/DiscussionsSection.vue";
import Resources from "@/components/Group/Sections/ResourcesSection.vue"; import Resources from "@/components/Group/Sections/ResourcesSection.vue";
import Posts from "@/components/Group/Sections/PostsSection.vue"; import Posts from "@/components/Group/Sections/PostsSection.vue";

View File

@ -92,7 +92,7 @@ import { useMutation, useQuery } from "@vue/apollo-composable";
import { IUser } from "@/types/current-user.model"; import { IUser } from "@/types/current-user.model";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, inject } from "vue"; import { computed, inject } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";

View File

@ -32,7 +32,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import SettingMenuSection from "@/components/Settings/SettingMenuSection.vue"; import SettingMenuSection from "@/components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue"; import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";

View File

@ -166,7 +166,7 @@ import SkeletonActivityItem from "../../components/Activity/SkeletonActivityItem
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import TimelineText from "vue-material-design-icons/TimelineText.vue"; import TimelineText from "vue-material-design-icons/TimelineText.vue";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { enumTransformer, useRouteQuery } from "vue-use-route-query"; import { enumTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, defineAsyncComponent, ref } from "vue"; import { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";

View File

@ -38,7 +38,7 @@ import { computed, reactive } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouteQuery } from "vue-use-route-query"; import { useRouteQuery } from "vue-use-route-query";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const router = useRouter(); const router = useRouter();
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });

View File

@ -442,7 +442,7 @@ import { displayNameAndUsername, displayName } from "../../types/actor";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { formatDateTimeString } from "@/filters/datetime"; import { formatDateTimeString } from "@/filters/datetime";

View File

@ -95,7 +95,7 @@ import { Paginate } from "@/types/paginate";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
import { import {
enumTransformer, enumTransformer,

View File

@ -414,7 +414,7 @@ import { GraphQLError } from "graphql";
import { ApolloCache, FetchResult } from "@apollo/client/core"; import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useLazyQuery, useMutation, useQuery } from "@vue/apollo-composable"; import { useLazyQuery, useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ref, computed, inject } from "vue"; import { ref, computed, inject } from "vue";

View File

@ -77,7 +77,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRouteQuery } from "vue-use-route-query"; import { useRouteQuery } from "vue-use-route-query";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";

View File

@ -71,7 +71,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { DEVICE_ACTIVATION } from "@/graphql/application"; import { DEVICE_ACTIVATION } from "@/graphql/application";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import AuthorizeApplication from "@/components/OAuth/AuthorizeApplication.vue"; import AuthorizeApplication from "@/components/OAuth/AuthorizeApplication.vue";

View File

@ -55,7 +55,7 @@
</section> </section>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";

View File

@ -154,7 +154,7 @@ import {
useCurrentActorClient, useCurrentActorClient,
usePersonStatusGroup, usePersonStatusGroup,
} from "@/composition/apollo/actor"; } from "@/composition/apollo/actor";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { computed, inject, onMounted, ref, watch } from "vue"; import { computed, inject, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";

View File

@ -83,7 +83,7 @@ import MultiPostListItem from "../../components/Post/MultiPostListItem.vue";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue"; import { computed } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { MemberRole } from "@/types/enums"; import { MemberRole } from "@/types/enums";

View File

@ -268,7 +268,7 @@ import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref } from "vue"; import { computed, inject, ref } from "vue";
import { IPost } from "@/types/post.model"; import { IPost } from "@/types/post.model";
import { DELETE_POST, FETCH_POST } from "@/graphql/post"; import { DELETE_POST, FETCH_POST } from "@/graphql/post";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { formatDateTimeString } from "@/filters/datetime"; import { formatDateTimeString } from "@/filters/datetime";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useCreateReport } from "@/composition/apollo/report"; import { useCreateReport } from "@/composition/apollo/report";

View File

@ -245,7 +245,7 @@ import { computed, nextTick, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { integerTransformer, useRouteQuery } from "vue-use-route-query"; import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useResourceProviders } from "@/composition/apollo/config"; import { useResourceProviders } from "@/composition/apollo/config";
import Folder from "vue-material-design-icons/Folder.vue"; import Folder from "vue-material-design-icons/Folder.vue";
import Link from "vue-material-design-icons/Link.vue"; import Link from "vue-material-design-icons/Link.vue";

View File

@ -770,7 +770,7 @@ import Calendar from "vue-material-design-icons/Calendar.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue"; import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
import Magnify from "vue-material-design-icons/Magnify.vue"; import Magnify from "vue-material-design-icons/Magnify.vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import type { Locale } from "date-fns"; import type { Locale } from "date-fns";
import FilterSection from "@/components/Search/filters/FilterSection.vue"; import FilterSection from "@/components/Search/filters/FilterSection.vue";
import { listShortDisjunctionFormatter } from "@/utils/listFormat"; import { listShortDisjunctionFormatter } from "@/utils/listFormat";

View File

@ -235,7 +235,7 @@ import { useLoggedUser } from "@/composition/apollo/user";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { IAuthProvider } from "@/types/enums"; import { IAuthProvider } from "@/types/enums";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { GraphQLError } from "graphql/error/GraphQLError"; import { GraphQLError } from "graphql/error/GraphQLError";
import { computed, inject, ref } from "vue"; import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";

View File

@ -82,7 +82,7 @@ import {
REVOKED_AUTHORIZED_APPLICATION, REVOKED_AUTHORIZED_APPLICATION,
} from "@/graphql/application"; } from "@/graphql/application";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, inject } from "vue"; import { computed, inject } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import RouteName from "../../router/name"; import RouteName from "../../router/name";

View File

@ -339,7 +339,7 @@ import {
} from "vue"; } from "vue";
import { IConfig } from "@/types/config.model"; import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { Dialog } from "@/plugins/dialog"; import { Dialog } from "@/plugins/dialog";
type NotificationSubType = { label: string; id: string }; type NotificationSubType = { label: string; id: string };

View File

@ -148,7 +148,7 @@ import { AddressSearchType } from "@/types/enums";
import { Address, IAddress } from "@/types/address.model"; import { Address, IAddress } from "@/types/address.model";
import { useTimezones } from "@/composition/apollo/config"; import { useTimezones } from "@/composition/apollo/config";
import { useUserSettings, updateLocale } from "@/composition/apollo/user"; import { useUserSettings, updateLocale } from "@/composition/apollo/user";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, defineAsyncComponent, ref, watch } from "vue"; import { computed, defineAsyncComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";

View File

@ -13,7 +13,7 @@
import SettingsMenu from "../components/Settings/SettingsMenu.vue"; import SettingsMenu from "../components/Settings/SettingsMenu.vue";
import { computed } from "vue"; import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });

View File

@ -51,7 +51,7 @@ import RouteName from "../../router/name";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core"; import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
const props = defineProps<{ id: string }>(); const props = defineProps<{ id: string }>();

View File

@ -70,7 +70,7 @@ import { ITodoList } from "@/types/todolist";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { useGroup } from "@/composition/apollo/group"; import { useGroup } from "@/composition/apollo/group";
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";

View File

@ -34,7 +34,7 @@ import FullTodo from "@/components/Todo/FullTodo.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { displayName, usernameWithDomain } from "@/types/actor"; import { displayName, usernameWithDomain } from "@/types/actor";
import { useQuery } from "@vue/apollo-composable"; import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps<{ todoId: string }>(); const props = defineProps<{ todoId: string }>();

View File

@ -143,7 +143,7 @@ import AuthProviders from "@/components/User/AuthProviders.vue";
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import { LoginError, LoginErrorCode } from "@/types/enums"; import { LoginError, LoginErrorCode } from "@/types/enums";
import { useCurrentUserClient } from "@/composition/apollo/user"; import { useCurrentUserClient } from "@/composition/apollo/user";
import { useHead } from "@unhead/vue"; import { useHead } from "@/utils/head";
import { enumTransformer, useRouteQuery } from "vue-use-route-query"; import { enumTransformer, useRouteQuery } from "vue-use-route-query";
import { useLazyCurrentUserIdentities } from "@/composition/apollo/actor"; import { useLazyCurrentUserIdentities } from "@/composition/apollo/actor";

Some files were not shown because too many files have changed in this diff Show More