Merge remote-tracking branch 'ppom/1397-instance-personnalization' into 5.0-WIP

This commit is contained in:
setop 2024-03-15 20:28:23 +01:00
commit a815a07242
109 changed files with 950 additions and 197 deletions

View File

@ -19,8 +19,8 @@ stop:
@bash docker/message.sh "Mobilizon is stopped"
test: stop
@bash docker/message.sh "Running tests"
docker compose -f docker compose.yml -f docker compose.test.yml run api mix prepare_test
docker compose -f docker compose.yml -f docker compose.test.yml run api mix test $(only)
docker compose -f docker-compose.yml -f docker-compose.test.yml run api mix prepare_test
docker compose -f docker-compose.yml -f docker-compose.test.yml run api mix test $(only)
@bash docker/message.sh "Done running tests"
format:
docker compose run --rm api bash -c "mix format && mix credo --strict"

View File

@ -5,11 +5,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Admin, Config, Events, Instances, Users}
alias Mobilizon.{Actors, Admin, Config, Events, Instances, Media, Users}
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Admin.{ActionLog, Setting}
alias Mobilizon.Admin.{ActionLog, Setting, SettingMedia}
alias Mobilizon.Cldr.Language
alias Mobilizon.Config
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
@ -20,6 +19,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
alias Mobilizon.GraphQL.Resolvers.Media, as: MediaResolver
import Mobilizon.Web.Gettext
require Logger
@ -268,8 +270,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
with {:ok, res} <- Admin.save_settings("instance", args),
res <-
res
|> Enum.map(fn {key, %Setting{value: value}} ->
{key, Admin.get_setting_value(value)}
|> Enum.map(fn {key, val} ->
case val do
%Setting{value: value} -> {key, Admin.get_setting_value(value)}
%SettingMedia{media: media} -> {key, media}
end
end)
|> Enum.into(%{}),
:ok <- eventually_update_instance_actor(res) do
@ -284,6 +289,38 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
end
@spec get_media_setting(any(), any(), Absinthe.Resolution.t()) ::
{:ok, Media.t()} | {:error, String.t()}
def get_media_setting(_parent, %{group: group, name: name}, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
{:ok, MediaResolver.transform_media(Admin.get_admin_setting_media(group, name, nil))}
end
def get_media_setting(_parent, _args, _resolution) do
{:error,
dgettext("errors", "You need to be logged-in and an administrator to access admin settings")}
end
@spec get_instance_logo(any(), any(), Absinthe.Resolution.t()) ::
{:ok, Media.t() | nil} | {:error, String.t()}
def get_instance_logo(parent, _args, resolution) do
get_media_setting(parent, %{group: "instance", name: "instance_logo"}, resolution)
end
@spec get_instance_favicon(any(), any(), Absinthe.Resolution.t()) ::
{:ok, Media.t() | nil} | {:error, String.t()}
def get_instance_favicon(parent, _args, resolution) do
get_media_setting(parent, %{group: "instance", name: "instance_favicon"}, resolution)
end
@spec get_default_picture(any(), any(), Absinthe.Resolution.t()) ::
{:ok, Media.t() | nil} | {:error, String.t()}
def get_default_picture(parent, _args, resolution) do
get_media_setting(parent, %{group: "instance", name: "default_picture"}, resolution)
end
@spec update_user(any, map(), Absinthe.Resolution.t()) ::
{:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()}
| {:ok, Mobilizon.Users.User.t()}

View File

@ -5,8 +5,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
alias Mobilizon.Config
alias Mobilizon.Events.Categories
alias Mobilizon.Medias.Media
alias Mobilizon.Service.{AntiSpam, FrontEndAnalytics}
alias Mobilizon.GraphQL.Resolvers.Media, as: MediaResolver
@doc """
Gets config.
"""
@ -31,6 +34,16 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
{:ok, data}
end
@spec instance_logo(any(), map(), Absinthe.Resolution.t()) :: {:ok, Media.t()}
def instance_logo(_parent, _params, _resolution) do
{:ok, MediaResolver.transform_media(Config.instance_logo())}
end
@spec default_picture(any(), map(), Absinthe.Resolution.t()) :: {:ok, Media.t()}
def default_picture(_parent, _params, _resolution) do
{:ok, MediaResolver.transform_media(Config.default_picture())}
end
@spec terms(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()}
def terms(_parent, %{locale: locale}, _resolution) do
type = Config.instance_terms_type()
@ -99,6 +112,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
long_description: Config.instance_long_description(),
slogan: Config.instance_slogan(),
languages: Config.instance_languages(),
instance_logo: Config.instance_logo(),
primary_color: Config.primary_color(),
secondary_color: Config.secondary_color(),
default_picture: Config.default_picture(),
anonymous: %{
participation: %{
allowed: Config.anonymous_participation?(),

View File

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

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_url, :string, description: "The instance's terms URL")
field(:instance_logo, :media,
description: "The instance's logo",
resolve: &Admin.get_instance_logo/3
)
field(:instance_favicon, :media,
description: "The instance's favicon",
resolve: &Admin.get_instance_favicon/3
)
field(:default_picture, :media,
description: "The default picture",
resolve: &Admin.get_default_picture/3
)
field(:primary_color, :string, description: "The instance's primary color")
field(:secondary_color, :string, description: "The instance's secondary color")
field(:instance_privacy_policy, :string,
description: "The instance's privacy policy body text"
)
@ -412,6 +430,25 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
arg(:instance_long_description, :string, description: "The instance's long description")
arg(:instance_slogan, :string, description: "The instance's slogan")
arg(:contact, :string, description: "The instance's contact details")
arg(:instance_logo, :media_input,
description:
"The instance's logo, either as an object or directly the ID of an existing media"
)
arg(:instance_favicon, :media_input,
description:
"The instance's favicon, either as an object or directly the ID of an existing media"
)
arg(:default_picture, :media_input,
description:
"The default picture, either as an object or directly the ID of an existing media"
)
arg(:primary_color, :string, description: "The instance's primary color")
arg(:secondary_color, :string, description: "The instance's secondary color")
arg(:instance_terms, :string, description: "The instance's terms body text")
arg(:instance_terms_type, :instance_terms_type, description: "The instance's terms type")
arg(:instance_terms_url, :string, description: "The instance's terms URL")

View File

@ -60,6 +60,17 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
resolve(&Config.terms/3)
end
field(:instance_logo, :media, description: "The instance's logo") do
resolve(&Config.instance_logo/3)
end
field(:default_picture, :media, description: "The default picture") do
resolve(&Config.default_picture/3)
end
field(:primary_color, :string, description: "The instance's primary color")
field(:secondary_color, :string, description: "The instance's secondary color")
field(:privacy, :privacy, description: "The instance's privacy policy") do
arg(:locale, :string,
default_value: "en",

View File

@ -9,7 +9,8 @@ defmodule Mobilizon.Admin do
alias Mobilizon.Actors.Actor
alias Mobilizon.{Admin, Users}
alias Mobilizon.Admin.ActionLog
alias Mobilizon.Admin.Setting
alias Mobilizon.Admin.{Setting, SettingMedia}
alias Mobilizon.Medias.Media
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
@ -78,9 +79,47 @@ defmodule Mobilizon.Admin do
defp stringify_struct(struct), do: struct
@spec get_all_admin_settings :: list(Setting.t())
@spec get_all_admin_settings :: map()
def get_all_admin_settings do
Repo.all(Setting)
medias =
SettingMedia
|> Repo.all()
|> Repo.preload(:media)
|> Enum.map(fn %SettingMedia{group: group, name: name, media: media} ->
{group, name, media}
end)
values =
Setting
|> Repo.all()
|> Enum.map(fn %Setting{group: group, name: name, value: value} ->
{group, name, get_setting_value(value)}
end)
all_settings = Enum.concat(values, medias)
Enum.reduce(
all_settings,
%{},
# For each {group,name,value}
fn {group, name, value}, acc ->
# We update the %{group: map} in the accumulator
{_, new_acc} =
Map.get_and_update(
acc,
group,
# We put the %{name: value} into the %{group: map}
fn group_map ->
{
group_map,
Map.put(group_map || %{}, name, value)
}
end
)
new_acc
end
)
end
@spec get_admin_setting_value(String.t(), String.t(), String.t() | nil) ::
@ -119,21 +158,40 @@ defmodule Mobilizon.Admin do
end
end
@spec get_admin_setting_media(String.t(), String.t(), String.t() | nil) ::
{:ok, Media.t()} | {:error, :not_found} | nil
def get_admin_setting_media(group, name, fallback \\ nil)
when is_binary(group) and is_binary(name) do
case SettingMedia
|> where(group: ^group)
|> where(name: ^name)
|> preload(:media)
|> Repo.one() do
nil ->
fallback
%SettingMedia{media: media} ->
media
%SettingMedia{} ->
fallback
end
end
@spec save_settings(String.t(), map()) :: {:ok, any} | {:error, any}
def save_settings(group, args) do
{medias, values} = Map.split(args, [:instance_logo, :instance_favicon, :default_picture])
Multi.new()
|> do_save_setting(group, args)
|> do_save_media_setting(group, medias)
|> do_save_value_setting(group, values)
|> Repo.transaction()
end
def clear_settings(group) do
Setting |> where([s], s.group == ^group) |> Repo.delete_all()
end
@spec do_save_value_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t()
defp do_save_value_setting(transaction, _group, args) when args == %{}, do: transaction
@spec do_save_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t()
defp do_save_setting(transaction, _group, args) when args == %{}, do: transaction
defp do_save_setting(transaction, group, args) do
defp do_save_value_setting(transaction, group, args) do
key = hd(Map.keys(args))
{val, rest} = Map.pop(args, key)
@ -150,7 +208,40 @@ defmodule Mobilizon.Admin do
conflict_target: [:group, :name]
)
do_save_setting(transaction, group, rest)
do_save_value_setting(transaction, group, rest)
end
@spec do_save_media_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t()
defp do_save_media_setting(transaction, _group, args) when args == %{}, do: transaction
defp do_save_media_setting(transaction, group, args) do
key = hd(Map.keys(args))
{val, rest} = Map.pop(args, key)
transaction =
case val do
val ->
Multi.insert(
transaction,
key,
SettingMedia.changeset(%SettingMedia{}, %{
group: group,
name: Atom.to_string(key),
media: val
}),
on_conflict: :replace_all,
conflict_target: [:group, :name]
)
end
do_save_media_setting(transaction, group, rest)
end
def clear_settings(group) do
Multi.new()
|> Multi.delete_all(:settings, Setting |> where([s], s.group == ^group))
|> Multi.delete_all(:settings_medias, SettingMedia |> where([s], s.group == ^group))
|> Repo.transaction()
end
@spec convert_to_string(any()) :: String.t()

View File

@ -4,6 +4,7 @@ defmodule Mobilizon.Admin.Setting do
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
@required_attrs [:group, :name]
@optional_attrs [:value]
@ -32,3 +33,93 @@ defmodule Mobilizon.Admin.Setting do
|> unique_constraint(:group, name: :admin_settings_group_name_index)
end
end
defmodule Mobilizon.Admin.SettingMedia do
@moduledoc """
A Key-Value settings table for media settings
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Medias
alias Mobilizon.Medias.Media
alias Mobilizon.Storage.Repo
@required_attrs [:group, :name]
@type t :: %{
group: String.t(),
name: String.t(),
media: Media.t()
}
schema "admin_settings_medias" do
field(:group, :string)
field(:name, :string)
belongs_to(:media, Media, on_replace: :delete)
timestamps()
end
@doc false
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(setting_media, attrs) do
setting_media
|> Repo.preload(:media)
|> cast(attrs, @required_attrs)
|> put_media(attrs)
|> validate_required(@required_attrs)
|> unique_constraint(:group, name: :admin_settings_medias_group_name_index)
end
# # In case the provided media is an existing one
@spec put_media(Changeset.t(), map) :: Changeset.t()
defp put_media(%Changeset{} = changeset, %{media: %{media_id: id}}) do
%Media{} = media = Medias.get_media!(id)
put_assoc(changeset, :media, media)
end
# In case it's a new media
defp put_media(%Changeset{} = changeset, %{media: %{media: media}}) do
{:ok, media} = upload_media(media)
put_assoc(changeset, :media, media)
end
# In case there is no media
defp put_media(%Changeset{} = changeset, _media) do
put_assoc(changeset, :media, nil)
end
import Mobilizon.Web.Gettext
@spec upload_media(map) :: {:ok, Media.t()} | {:error, any}
defp upload_media(%{file: %Plug.Upload{} = file} = args) do
with {:ok,
%{
name: _name,
url: url,
content_type: content_type,
size: size
} = uploaded} <-
Mobilizon.Web.Upload.store(file),
args <-
args
|> Map.put(:url, url)
|> Map.put(:size, size)
|> Map.put(:content_type, content_type),
{:ok, media = %Media{}} <-
Medias.create_media(%{
file: args,
actor_id: Map.get(args, :actor_id, Relay.get_actor().id),
metadata: Map.take(uploaded, [:width, :height, :blurhash])
}) do
{:ok, media}
else
{:error, :mime_type_not_allowed} ->
{:error, dgettext("errors", "File doesn't have an allowed MIME type.")}
error ->
{:error, error}
end
end
end

View File

@ -4,7 +4,8 @@ defmodule Mobilizon.Config do
"""
alias Mobilizon.Actors
alias Mobilizon.Admin.Setting
alias Mobilizon.Admin
alias Mobilizon.Medias.Media
alias Mobilizon.Service.GitStatus
require Logger
import Mobilizon.Service.Export.Participants.Common, only: [enabled_formats: 0]
@ -29,56 +30,18 @@ defmodule Mobilizon.Config do
@spec instance_config :: mobilizon_config
def instance_config, do: Application.get_env(:mobilizon, :instance)
@spec db_instance_config :: list(Setting.t())
def db_instance_config, do: Mobilizon.Admin.get_all_admin_settings()
@spec config_cache :: map()
def config_cache do
case Cachex.fetch(:config, :all_db_config, fn _key ->
value =
Enum.reduce(
Mobilizon.Admin.get_all_admin_settings(),
%{},
&arrange_values/2
)
{:commit, value}
end) do
case Cachex.fetch(
:config,
:all_db_config,
fn _key -> {:commit, Admin.get_all_admin_settings()} end
) do
{status, value} when status in [:ok, :commit] -> value
_err -> %{}
end
end
@spec arrange_values(Setting.t(), map()) :: map()
defp arrange_values(setting, acc) do
{_, new_data} =
Map.get_and_update(acc, setting.group, fn current_value ->
new_value = current_value || %{}
{current_value, Map.put(new_value, setting.name, process_value(setting.value))}
end)
new_data
end
@spec process_value(String.t() | nil) :: any()
defp process_value(nil), do: nil
defp process_value(""), do: nil
defp process_value(value) do
case Jason.decode(value) do
{:ok, val} ->
val
{:error, _} ->
case value do
"true" -> true
"false" -> false
value -> value
end
end
end
@spec config_cached_value(String.t(), String.t(), String.t()) :: any()
def config_cached_value(group, name, fallback \\ nil) do
config_cache()
@ -115,10 +78,23 @@ defmodule Mobilizon.Config do
@spec instance_slogan :: String.t() | nil
def instance_slogan, do: config_cached_value("instance", "instance_slogan")
@spec instance_logo :: Media.t() | nil
def instance_logo, do: config_cached_value("instance", "instance_logo")
@spec instance_favicon :: Media.t() | nil
def instance_favicon, do: config_cached_value("instance", "instance_favicon")
@spec default_picture :: Media.t() | nil
def default_picture, do: config_cached_value("instance", "default_picture")
@spec primary_color :: Media.t() | nil
def primary_color, do: config_cached_value("instance", "primary_color")
@spec secondary_color :: Media.t() | nil
def secondary_color, do: config_cached_value("instance", "secondary_color")
@spec contact :: String.t() | nil
def contact do
config_cached_value("instance", "contact")
end
def contact, do: config_cached_value("instance", "contact")
@spec instance_terms(String.t()) :: String.t()
def instance_terms(locale \\ "en") do
@ -473,6 +449,9 @@ defmodule Mobilizon.Config do
instance_slogan: instance_slogan(),
registrations_open: instance_registrations_open?(),
contact: contact(),
primary_color: primary_color(),
secondary_color: secondary_color(),
instance_logo: instance_logo(),
instance_terms: instance_terms(),
instance_terms_type: instance_terms_type(),
instance_terms_url: instance_terms_url(),

View File

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

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,
do:
~w(index.html manifest.json manifest.webmanifest service-worker.js css fonts img js favicon.ico robots.txt assets)
do: ~w(index.html service-worker.js css fonts img js robots.txt assets)
def controller do
quote do

View File

@ -113,6 +113,12 @@ defmodule Mobilizon.Web.Router do
get("/nodeinfo/:version", NodeInfoController, :nodeinfo)
end
scope "/", Mobilizon.Web do
get("/manifest.webmanifest", ManifestController, :manifest)
get("/manifest.json", ManifestController, :manifest)
get("/favicon.ico", ManifestController, :favicon)
end
scope "/", Mobilizon.Web do
pipe_through(:activity_pub_and_html)
pipe_through(:activity_pub_signature)

View File

@ -4,15 +4,16 @@
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<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="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content={theme_color()} />
<script>
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 {
document.documentElement.classList.remove('dark')
document.documentElement.classList.remove('dark')
}
</script>
<%= if root?(assigns) do %>
@ -24,6 +25,7 @@
<%= Vite.vite_client() %>
<%= Vite.vite_snippet("src/main.ts") %>
</head>
<body>
<noscript>
<strong>

View File

@ -6,6 +6,7 @@ defmodule Mobilizon.Web.PageView do
use Mobilizon.Web, :view
alias Mobilizon.Actors.Actor
alias Mobilizon.Config
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post
@ -91,4 +92,27 @@ defmodule Mobilizon.Web.PageView do
def root?(assigns) do
assigns |> Map.get(:conn, %{request_path: "/"}) |> Map.get(:request_path, "/") == "/"
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

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"
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"
contact: String
@ -1881,6 +1893,15 @@ type RootMutationType {
"The instance's contact details"
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"
instanceTerms: String
@ -3704,6 +3725,15 @@ type AdminSettings {
"The instance's contact details"
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"
instanceTerms: String

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<template>
<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 }"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 248.16 46.78"
@ -30,9 +31,14 @@
/>
</g>
</svg>
<img v-else alt="" class="max-h-12 w-auto" :src="instanceLogoUrl" />
</template>
<script lang="ts" setup>
import { useInstanceLogoUrl } from "@/composition/apollo/config";
const { instanceLogoUrl } = useInstanceLogoUrl();
withDefaults(
defineProps<{
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"
id="navbar"
>
<div
class="container mx-auto flex flex-wrap items-center mx-auto gap-2 sm:gap-4"
>
<div class="container mx-auto flex flex-wrap items-center gap-2 sm:gap-4">
<router-link
:to="{ name: RouteName.HOME }"
class="flex items-center"

View File

@ -90,7 +90,7 @@
</template>
<script lang="ts" setup>
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
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 { useMutation } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { IActor } from "@/types/actor";
import { IEvent } from "@/types/event.model";
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 { useMutation } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
const { t } = useI18n({ useScope: "global" });

View File

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

View File

@ -146,7 +146,7 @@ import { useFetchEventBasic } from "@/composition/apollo/event";
import { useAnonymousActorId } from "@/composition/apollo/config";
import { computed, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useMutation } from "@vue/apollo-composable";
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 { computed } from "vue";
import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n";
const props = defineProps<{ uuid: string }>();

View File

@ -4,12 +4,15 @@ import {
ANONYMOUS_ACTOR_ID,
ANONYMOUS_PARTICIPATION_CONFIG,
ANONYMOUS_REPORTS_CONFIG,
DEFAULT_PICTURE,
DEMO_MODE,
LONG_EVENTS,
EVENT_CATEGORIES,
EVENT_PARTICIPANTS,
FEATURES,
GEOCODING_AUTOCOMPLETE,
COLORS,
INSTANCE_LOGO,
LOCATION,
MAPS_TILES,
REGISTRATIONS,
@ -77,6 +80,36 @@ export function useInstanceName() {
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() {
const { result, error, loading } = useQuery<{
config: Pick<IConfig, "anonymous">;

View File

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

View File

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

View File

@ -13,6 +13,21 @@ export const CONFIG = gql`
longEvents
countryCode
languages
primaryColor
secondaryColor
instanceLogo {
url
}
defaultPicture {
id
url
name
metadata {
width
height
blurhash
}
}
eventCategories {
id
label
@ -463,6 +478,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`
query Registrations {
config {

View File

@ -418,6 +418,14 @@
"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",
"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",
"You have been invited by {invitedBy} to the following group:": "You have been invited by {invitedBy} to the following group:",
"Accept": "Accept",

View File

@ -627,6 +627,14 @@
"Local times ({timezone})": "Heures locales ({timezone})",
"Locality": "Commune",
"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 out": "Se déconnecter",
"Login": "Se connecter",

View File

@ -57,6 +57,21 @@ apolloClient
})
.then(({ data: configData }) => {
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();

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import type { Ref } from "vue";
export interface IMedia {
id: string;
url: string;
@ -21,3 +23,9 @@ export interface IMediaMetadata {
height?: number;
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(
obj: IMedia | null | undefined
@ -29,18 +30,83 @@ export function buildFileVariable(
};
}
export function readFileAsync(
file: File
): Promise<string | ArrayBuffer | null> {
export function readFileAsync(file: File): Promise<ArrayBuffer | null> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
resolve(reader.result as ArrayBuffer);
};
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 { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -235,7 +235,7 @@ import {
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref, watch } from "vue";
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 { Notifier } from "@/plugins/notifier";
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 { useI18n } from "vue-i18n";
import { computed } from "vue";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import {
useRouteQuery,
booleanTransformer,

View File

@ -58,6 +58,68 @@
</small>
<o-input v-model="settingsToWrite.contact" id="instance-contact" />
</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-switch v-model="settingsToWrite.registrationsOpen">
<p
@ -389,15 +451,29 @@ import RouteName from "@/router/name";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { ref, computed, watch, inject } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
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 = {
instanceName: "",
instanceDescription: "",
instanceSlogan: "",
instanceLongDescription: "",
contact: "",
instanceLogo: null,
instanceFavicon: null,
defaultPicture: null,
primaryColor: "",
secondaryColor: "",
instanceTerms: "",
instanceTermsType: InstanceTermsType.DEFAULT,
instanceTermsUrl: null,
@ -409,12 +485,30 @@ const defaultAdminSettings: IAdminSettings = {
instanceLanguages: [],
};
const { result: adminSettingsResult } = useQuery<{
const { onResult: onAdminSettingsResult } = useQuery<{
adminSettings: IAdminSettings;
}>(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[] }>(
LANGUAGES
@ -463,6 +557,9 @@ const {
} = useMutation(SAVE_ADMIN_SETTINGS);
saveAdminSettingsDone(() => {
instanceLogo.firstHash = instanceLogo.hash;
instanceFavicon.firstHash = instanceFavicon.hash;
defaultPicture.firstHash = defaultPicture.hash;
notifier?.success(t("Admin settings successfully saved.") as string);
});
@ -472,11 +569,29 @@ saveAdminSettingsError((e) => {
});
const updateSettings = async (): Promise<void> => {
const variables = { ...settingsToWrite.value };
console.debug("updating settings with variables", variables);
const 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);
};
const maxSize = useDefaultMaxSize();
const getFilteredLanguages = (text: string): void => {
filteredLanguages.value = languages.value
? languages.value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -171,7 +171,7 @@ import {
computed,
inject,
} from "vue";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useRouter } from "vue-router";
import { useCurrentActorClient } from "@/composition/apollo/actor";
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 { useI18n } from "vue-i18n";
import { useRouteQuery, integerTransformer } from "vue-use-route-query";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { computed } from "vue";
const page = useRouteQuery("page", 1, integerTransformer);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -236,7 +236,7 @@ import {
import { Locale } from "date-fns";
import { useI18n } from "vue-i18n";
import { useRestrictions } from "@/composition/apollo/config";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
const EventParticipationCard = defineAsyncComponent(
() => 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 { Notifier } from "@/plugins/notifier";
import Tag from "@/components/TagElement.vue";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130;

View File

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

View File

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

View File

@ -250,7 +250,7 @@ import {
} from "@/graphql/member";
import { usernameWithDomain, displayName, IGroup } from "@/types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@vue/apollo-composable";
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 { useRouter } from "vue-router";
import { Dialog } from "@/plugins/dialog";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { Notifier } from "@/plugins/notifier";
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 { useI18n } from "vue-i18n";
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 Resources from "@/components/Group/Sections/ResourcesSection.vue";
import Posts from "@/components/Group/Sections/PostsSection.vue";

View File

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

View File

@ -32,7 +32,7 @@
<script lang="ts" setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import RouteName from "@/router/name";
import SettingMenuSection from "@/components/Settings/SettingMenuSection.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 TimelineText from "vue-material-design-icons/TimelineText.vue";
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 { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -154,7 +154,7 @@ import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n";
import { computed, inject, onMounted, ref, watch } from "vue";
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 { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useI18n } from "vue-i18n";
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 { IPost } from "@/types/post.model";
import { DELETE_POST, FETCH_POST } from "@/graphql/post";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { formatDateTimeString } from "@/filters/datetime";
import { useRouter } from "vue-router";
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 { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useResourceProviders } from "@/composition/apollo/config";
import Folder from "vue-material-design-icons/Folder.vue";
import Link from "vue-material-design-icons/Link.vue";

View File

@ -818,7 +818,7 @@ import CalendarStar from "vue-material-design-icons/CalendarStar.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.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 FilterSection from "@/components/Search/filters/FilterSection.vue";
import { listShortDisjunctionFormatter } from "@/utils/listFormat";

View File

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

View File

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

View File

@ -339,7 +339,7 @@ import {
} from "vue";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { Dialog } from "@/plugins/dialog";
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 { useTimezones } from "@/composition/apollo/config";
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 { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";

View File

@ -13,7 +13,7 @@
import SettingsMenu from "../components/Settings/SettingsMenu.vue";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
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 { useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { computed, ref } from "vue";
const props = defineProps<{ id: string }>();

View File

@ -70,7 +70,7 @@ import { ITodoList } from "@/types/todolist";
import RouteName from "../../router/name";
import { useGroup } from "@/composition/apollo/group";
import { computed, reactive } from "vue";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { useI18n } from "vue-i18n";
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 { displayName, usernameWithDomain } from "@/types/actor";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@unhead/vue";
import { useHead } from "@/utils/head";
import { computed } from "vue";
const props = defineProps<{ todoId: string }>();

View File

@ -143,7 +143,7 @@ import AuthProviders from "@/components/User/AuthProviders.vue";
import RouteName from "@/router/name";
import { LoginError, LoginErrorCode } from "@/types/enums";
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 { useLazyCurrentUserIdentities } from "@/composition/apollo/actor";

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