CRUD on defaultPicture & instanceFavicon settings. defaultPicture in UI.

TODO: instanceFavicon in UI.
This commit is contained in:
ppom 2024-02-29 12:00:00 +01:00
parent c85d39a5aa
commit 0c668ef226
17 changed files with 281 additions and 71 deletions

View File

@ -267,7 +267,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
with {:ok, res} <- Admin.save_settings("instance", args),
with {:ok, res} <- Admin.save_settings("instance", IO.inspect(args, label: "SAVE_ARGS")),
res <-
res
|> Enum.map(fn {key, val} ->
@ -309,6 +309,18 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin 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

@ -35,10 +35,20 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
end
@spec instance_logo(any(), map(), Absinthe.Resolution.t()) :: {:ok, Media.t()}
def instance_logo(_parent, _locale, _resolution) do
def instance_logo(_parent, _params, _resolution) do
{:ok, MediaResolver.transform_media(Config.instance_logo())}
end
@spec instance_favicon(any(), map(), Absinthe.Resolution.t()) :: {:ok, Media.t()}
def instance_favicon(_parent, _params, _resolution) do
{:ok, MediaResolver.transform_media(Config.instance_favicon())}
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()
@ -107,6 +117,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
slogan: Config.instance_slogan(),
languages: Config.instance_languages(),
instance_logo: Config.instance_logo(),
instance_favicon: Config.instance_favicon(),
default_picture: Config.default_picture(),
anonymous: %{
participation: %{
allowed: Config.anonymous_participation?(),

View File

@ -128,6 +128,14 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
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(:instance_privacy_policy, :string,
description: "The instance's privacy policy body text"
@ -422,6 +430,14 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
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(:instance_terms, :string, description: "The instance's terms body text")
arg(:instance_terms_type, :instance_terms_type, description: "The instance's terms type")

View File

@ -63,6 +63,14 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
resolve(&Config.instance_logo/3)
end
field(:instance_favicon, :media, description: "The instance's favicon") do
resolve(&Config.instance_favicon/3)
end
field(:default_picture, :media, description: "The default picture") do
resolve(&Config.default_picture/3)
end
field(:privacy, :privacy, description: "The instance's privacy policy") do
arg(:locale, :string,
default_value: "en",

View File

@ -180,7 +180,9 @@ defmodule Mobilizon.Admin do
@spec save_settings(String.t(), map()) :: {:ok, any} | {:error, any}
def save_settings(group, args) do
{medias, values} = Map.split(args, [:instance_logo])
{medias, values} = Map.split(args, [:instance_logo, :instance_favicon, :default_picture])
IO.inspect(medias, label: "MEDIAS")
IO.inspect(values, label: "VALUES")
Multi.new()
|> do_save_media_setting(group, medias)

View File

@ -79,17 +79,16 @@ defmodule Mobilizon.Config do
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"
)
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 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

View File

@ -169,6 +169,15 @@ type Config {
"The instance's slogan"
slogan: String
"The instance's logo"
instanceLogo: Media
"The instance's favicon"
instanceFavicon: Media
"The default picture"
defaultPicture: Media
"The instance's contact details"
contact: String
@ -1881,6 +1890,12 @@ type RootMutationType {
"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
@ -3702,7 +3717,13 @@ type AdminSettings {
contact: String
"The instance's logo"
instanceLogo: MediaInput
instanceLogo: Media
"The instance's favicon"
instanceFavicon: Media
"The default picture"
defaultPicture: Media
"The instance's terms body text"
instanceTerms: 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

@ -4,11 +4,13 @@ import {
ANONYMOUS_ACTOR_ID,
ANONYMOUS_PARTICIPATION_CONFIG,
ANONYMOUS_REPORTS_CONFIG,
DEFAULT_PICTURE,
DEMO_MODE,
EVENT_CATEGORIES,
EVENT_PARTICIPANTS,
FEATURES,
GEOCODING_AUTOCOMPLETE,
INSTANCE_FAVICON,
INSTANCE_LOGO,
LOCATION,
MAPS_TILES,
@ -80,9 +82,7 @@ export function useInstanceName() {
export function useInstanceLogoUrl() {
const { result, error, loading } = useQuery<{
config: Pick<IConfig, "instanceLogo">;
}>(INSTANCE_LOGO, {
fetchPolicy: 'network-only',
});
}>(INSTANCE_LOGO);
const instanceLogoUrl = computed(
() => result.value?.config?.instanceLogo?.url
@ -90,6 +90,28 @@ export function useInstanceLogoUrl() {
return { instanceLogoUrl, error, loading };
}
export function useInstanceFaviconUrl() {
const { result, error, loading } = useQuery<{
config: Pick<IConfig, "instanceFavicon">;
}>(INSTANCE_FAVICON);
const instanceFaviconUrl = computed(
() => result.value?.config?.instanceFavicon?.url
);
return { instanceFaviconUrl, 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

@ -200,6 +200,16 @@ export const ADMIN_SETTINGS_FRAGMENT = gql`
url
name
}
instanceFavicon {
id
url
name
}
defaultPicture {
id
url
name
}
instanceTerms
instanceTermsType
instanceTermsUrl
@ -229,6 +239,8 @@ export const SAVE_ADMIN_SETTINGS = gql`
$instanceSlogan: String
$contact: String
$instanceLogo: MediaInput
$instanceFavicon: MediaInput
$defaultPicture: MediaInput
$instanceTerms: String
$instanceTermsType: InstanceTermsType
$instanceTermsUrl: String
@ -246,6 +258,8 @@ export const SAVE_ADMIN_SETTINGS = gql`
instanceSlogan: $instanceSlogan
contact: $contact
instanceLogo: $instanceLogo
instanceFavicon: $instanceFavicon
defaultPicture: $defaultPicture
instanceTerms: $instanceTerms
instanceTermsType: $instanceTermsType
instanceTermsUrl: $instanceTermsUrl

View File

@ -15,6 +15,19 @@ export const CONFIG = gql`
instanceLogo {
url
}
instanceFavicon {
url
}
defaultPicture {
id
url
name
metadata {
width
height
blurhash
}
}
eventCategories {
id
label
@ -169,9 +182,6 @@ export const ABOUT = gql`
query About {
config {
name
instanceLogo {
url
}
description
longDescription
slogan
@ -470,6 +480,33 @@ export const INSTANCE_LOGO = gql`
}
`;
export const INSTANCE_FAVICON = gql`
query InstanceFavicon {
config {
instanceFavicon {
url
}
}
}
`;
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

@ -27,6 +27,8 @@ export interface IAdminSettings {
instanceLongDescription: string;
contact: string;
instanceLogo: IMedia | null;
instanceFavicon: IMedia | null;
defaultPicture: IMedia | null;
instanceTerms: string;
instanceTermsType: InstanceTermsType;
instanceTermsUrl: string | null;

View File

@ -38,6 +38,8 @@ export interface IConfig {
contact: string;
slogan: string;
instanceLogo: { url: string };
instanceFavicon: { url: string };
defaultPicture: { url: 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
}

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
@ -45,9 +46,58 @@ export function readFileAsync(
});
}
export async function fileHash(file: File): Promise<ArrayBuffer | null> {
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);
return hash;
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 {
let 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

@ -58,8 +58,8 @@
</small>
<o-input v-model="settingsToWrite.contact" id="instance-contact" />
</div>
<div class="field flex flex-col">
<label class="" for="instance-logo">{{ t("Logo") }}</label>
<label class="field flex flex-col">
<p>{{ t("Logo") }}</p>
<small>
{{ t("Logo of the instance. Defaults to the upstream Mobilizon logo.") }}
</small>
@ -69,7 +69,31 @@
:textFallback="t('logo')"
:maxSize="maxSize"
/>
</div>
</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>
<o-field :label="t('Allow registrations')">
<o-switch v-model="settingsToWrite.registrationsOpen">
<p
@ -406,7 +430,7 @@ import type { Notifier } from "@/plugins/notifier";
// Media upload related
import PictureUpload from "@/components/PictureUpload.vue";
import { buildFileFromIMedia, fileHash } from "@/utils/image";
import { initWrappedMedia, loadWrappedMedia, asMediaInput } from "@/utils/image";
import { useDefaultMaxSize } from "@/composition/config";
const defaultAdminSettings: IAdminSettings = {
@ -416,6 +440,8 @@ const defaultAdminSettings: IAdminSettings = {
instanceLongDescription: "",
contact: "",
instanceLogo: null,
instanceFavicon: null,
defaultPicture: null,
instanceTerms: "",
instanceTermsType: InstanceTermsType.DEFAULT,
instanceTermsUrl: null,
@ -441,29 +467,18 @@ onAdminSettingsResult(async ({ data }) => {
adminSettings.value = {
...data.adminSettings,
} ?? defaultAdminSettings;
if (adminSettings.value.instanceLogo) {
try {
instanceLogoFile.value = await buildFileFromIMedia(adminSettings.value.instanceLogo);
instanceLogoServerHash.value = await fileHash(instanceLogoFile.value);
} catch (e) {
// Catch errors while building media
console.error("catched error while building media", e);
}
}
loadWrappedMedia(instanceLogo, adminSettings.value.instanceLogo);
loadWrappedMedia(instanceFavicon, adminSettings.value.instanceFavicon);
loadWrappedMedia(defaultPicture, adminSettings.value.defaultPicture);
});
let instanceLogoFile = ref<File | null>(null);
let instanceLogoServerHash = ref<ArrayBuffer | null>(null);
let instanceLogoHash = ref<ArrayBuffer | null>(null);
watch(instanceLogoFile, async () => {
console.debug("LOGOFILECHANGED", instanceLogoFile);
if (instanceLogoFile.value) {
instanceLogoHash = await fileHash(instanceLogoFile.value);
} else {
instanceLogoHash = null;
}
});
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
@ -512,7 +527,9 @@ const {
} = useMutation(SAVE_ADMIN_SETTINGS);
saveAdminSettingsDone(() => {
instanceLogoServerHash = instanceLogoHash;
instanceLogo.firstHash = instanceLogo.hash;
instanceFavicon.firstHash = instanceFavicon.hash;
defaultPicture.firstHash = defaultPicture.hash;
notifier?.success(t("Admin settings successfully saved.") as string);
});
@ -522,30 +539,12 @@ saveAdminSettingsError((e) => {
});
const updateSettings = async (): Promise<void> => {
console.debug("FILE AT UPDATE", instanceLogoFile.value);
let instanceLogoObj = {
instanceLogo: {},
};
if (instanceLogoFile.value) {
if (instanceLogoServerHash != instanceLogoHash) {
instanceLogoObj.instanceLogo = {
media: {
name: instanceLogoFile.value?.name,
alt: "",
file: instanceLogoFile.value,
},
};
} else {
instanceLogoObj.instanceLogo = {
mediaId: adminSettings.value?.instanceLogo?.id
};
}
}
const variables = {
...settingsToWrite.value,
...instanceLogoObj,
...asMediaInput(instanceLogo, "instanceLogo", adminSettings.value?.instanceLogo?.id),
...asMediaInput(instanceFavicon, "instanceFavicon", adminSettings.value?.instanceFavicon?.id),
...asMediaInput(defaultPicture, "defaultPicture", adminSettings.value?.defaultPicture?.id),
};
console.debug("updating settings with variables", variables);
saveAdminSettings(variables);
};

View File

@ -4,8 +4,8 @@ module.exports = {
theme: {
extend: {
colors: {
primary: "#1e7d97",
secondary: "#ffd599",
primary: "var(--custom-primary, #1e7d97)",
secondary: "var(--custom-secondary, #ffd599)",
"violet-title": "#3c376e",
tag: "rgb(var(--color-tag) / <alpha-value>)",
"frama-violet": "#725794",