feat: add animated logo as default logo

This commit is contained in:
Elias Schneider
2026-08-08 20:50:37 +02:00
parent 987d1a8b59
commit 0c9a03e519
16 changed files with 367 additions and 87 deletions
+140 -9
View File
@@ -1,18 +1,149 @@
<script module lang="ts">
type LogoVariant = 'light' | 'dark';
let defaultLogoHasAnimated = false;
const customLogoAvailability: Record<LogoVariant, boolean | undefined> = {
light: undefined,
dark: undefined
};
const finalLogoPath =
'M-250.368,-706.48C-166.912,-706.48 -83.456,-706.48 0,-706.48C149.377,-706.48 270.906,-584.953 270.906,-435.576C270.906,-376.876 252.438,-321.028 217.506,-274.062C183.258,-228.019 136.385,-194.563 81.955,-177.305C76.939,-175.715 71.924,-174.124 66.908,-172.534C54.955,-231.481 43.003,-290.429 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-142.779,6.48 -196.574,6.48 -250.368,6.48Z';
const animationValues = `
M-189.277,-455.654C-183.639,-520.099 -145.598,-577.247 -88.32,-607.319C-29.581,-638.158 40.991,-636.125 97.857,-601.956C125.29,-585.473 148.114,-562.328 164.212,-534.668C211.414,-453.567 192.341,-350.154 119.313,-291.228C92.569,-269.648 60.47,-255.723 26.44,-250.94C27.98,-283.752 29.51,-316.564 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-29.632,-316.622 -28.036,-283.781 -26.44,-250.94C-114.05,-263.255 -181.567,-334.403 -189.277,-422.534Z;
M-189.277,-455.654C-183.639,-520.099 -145.598,-577.247 -88.32,-607.319C-29.581,-638.158 40.991,-636.125 97.857,-601.956C125.29,-585.473 148.114,-562.328 164.212,-534.668C211.414,-453.567 192.341,-350.154 119.313,-291.228C92.569,-269.648 60.47,-255.723 26.44,-250.94C27.98,-283.752 29.51,-316.564 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-29.632,-316.622 -28.036,-283.781 -26.44,-250.94C-114.05,-263.255 -181.567,-334.403 -189.277,-422.534Z;
M-208.638,-523.827C-208.638,-580.969 -167.157,-631.565 -113.16,-644.659C-38.396,-662.79 43.369,-658.5 117.928,-641.978C156.432,-633.446 188.336,-602.071 196.106,-563.334C220.586,-441.292 205.573,-309.149 173.656,-196.614C158.003,-141.421 105.235,-133.862 43.22,-131.47C39.165,-204.106 35.105,-276.742 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.344,-369.823 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.343,-369.823 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-38.561,-276.801 -45.888,-204.136 -53.22,-131.47C-137.025,-137.627 -208.638,-173.118 -208.638,-262.267Z;
M-228,-592C-228,-641.7 -187.7,-682 -138,-682C-46,-682 46,-682 138,-682C187.7,-682 228,-641.7 228,-592C228,-428.67 228,-265.33 228,-102C228,-12 150,-12 60,-12C50.35,-124.46 40.7,-236.92 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-47.49,-236.98 -63.74,-124.49 -80,-12C-160,-12 -228,-12 -228,-102Z;
M-239.184,-594.24C-239.184,-649.466 -194.411,-694.24 -139.184,-694.24C-42.969,-694.24 53.238,-694.24 149.453,-694.24C204.679,-694.24 249.453,-649.466 249.453,-594.24C249.453,-430.415 249.453,-266.585 249.453,-102.76C249.453,-2.76 159.455,-2.76 63.454,-2.76C52.653,-118.299 41.852,-233.839 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.344,-369.823 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.343,-369.823 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-48.985,-233.897 -66.737,-118.328 -84.493,-2.76C-162.5,-2.76 -239.184,-2.76 -239.184,-102.76Z;
M-250.368,-596.48C-250.368,-657.233 -201.121,-706.48 -140.368,-706.48C-39.938,-706.48 60.476,-706.48 160.906,-706.48C221.659,-706.48 270.906,-657.233 270.906,-596.48C270.906,-432.16 270.906,-267.84 270.906,-103.52C270.906,6.48 168.91,6.48 66.908,6.48C54.955,-112.139 43.003,-230.757 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-165,6.48 -250.368,6.48 -250.368,-103.52Z;
M-250.368,-596.48C-250.368,-657.233 -201.121,-706.48 -140.368,-706.48C-39.938,-706.48 60.476,-706.48 160.906,-706.48C221.659,-706.48 270.906,-657.233 270.906,-596.48C270.906,-432.16 270.906,-267.84 270.906,-103.52C270.906,6.48 168.91,6.48 66.908,6.48C54.955,-112.139 43.003,-230.757 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-165,6.48 -250.368,6.48 -250.368,-103.52Z;
M-250.368,-651.48C-208.64,-681.856 -142.288,-706.48 -70.184,-706.48C54.72,-706.48 158.116,-640.023 215.906,-571.028C243.028,-538.647 257.527,-490.302 244.206,-435.271C219.135,-331.696 192.286,-233.857 176.43,-140.412C167.087,-85.348 120.417,-83.822 66.908,-83.027C54.955,-171.81 43.003,-260.593 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.344,-369.823 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.343,-369.823 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-153.889,6.48 -223.471,6.48 -250.368,-48.52Z;
M-250.368,-706.48C-166.912,-706.48 -83.456,-706.48 0,-706.48C149.377,-706.48 270.906,-584.953 270.906,-435.576C270.906,-376.876 252.438,-321.028 217.506,-274.062C183.258,-228.019 136.385,-194.563 81.955,-177.305C76.939,-175.715 71.924,-174.124 66.908,-172.534C54.955,-231.481 43.003,-290.429 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-142.779,6.48 -196.574,6.48 -250.368,6.48Z`;
const initialLogoPath = animationValues.trim().split(';', 1)[0];
</script>
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { cachedApplicationLogo } from '$lib/utils/cached-image-util';
import { cn } from '$lib/utils/style';
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
let { ...props }: HTMLAttributes<HTMLImageElement> = $props();
let {
class: className,
alt,
defaultOnly = false,
animate = true,
colorScheme
}: {
class?: string;
alt?: string;
defaultOnly?: boolean;
animate?: boolean;
colorScheme?: 'light' | 'dark';
} = $props();
const isLightMode = $derived(mode.current === 'light');
const initialIsLightMode = colorScheme ? colorScheme === 'light' : mode.current === 'light';
const initialLogoVariant: LogoVariant = initialIsLightMode ? 'light' : 'dark';
let customLogoAvailabilityState = $state<Record<LogoVariant, boolean | undefined>>({
...customLogoAvailability
});
let animateDefaultLogo = $state(
customLogoAvailability[initialLogoVariant] === false && claimDefaultLogoAnimation()
);
const isLightMode = $derived(colorScheme ? colorScheme === 'light' : mode.current === 'light');
const logoVariant = $derived(isLightMode ? 'light' : 'dark');
const logoAlt = $derived(alt ?? m.logo());
const useDefaultLogo = $derived(
defaultOnly || customLogoAvailabilityState[logoVariant] === false
);
const customLogoLoaded = $derived(customLogoAvailabilityState[logoVariant] === true);
function handleImageLoad() {
customLogoAvailability[logoVariant] = true;
customLogoAvailabilityState[logoVariant] = true;
}
function handleImageError() {
animateDefaultLogo = claimDefaultLogoAnimation();
customLogoAvailability[logoVariant] = false;
customLogoAvailabilityState[logoVariant] = false;
}
function claimDefaultLogoAnimation() {
if (!animate || defaultOnly || defaultLogoHasAnimated || $appConfigStore?.disableAnimations) {
return false;
}
defaultLogoHasAnimated = true;
return true;
}
</script>
<img
{...props}
class={cn('aspect-square object-contain', props.class)}
src={cachedApplicationLogo.getUrl(isLightMode)}
alt={m.logo()}
/>
{#if useDefaultLogo}
<svg
class={cn('aspect-square', isLightMode ? 'text-black' : 'text-white', className)}
viewBox="-346.211 -706.48 712.96 712.96"
role="img"
aria-label={logoAlt}
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
opacity={animateDefaultLogo ? 0 : 1}
d={animateDefaultLogo ? initialLogoPath : finalLogoPath}
>
{#if animateDefaultLogo}
<animate
attributeName="opacity"
dur="1.5s"
begin="0s"
fill="freeze"
keyTimes="0; 0.15; 1"
values="0; 1; 1"
/>
<animateTransform
attributeName="transform"
type="rotate"
dur="1.5s"
begin="0s"
fill="freeze"
calcMode="spline"
keyTimes="0; 0.20; 0.26; 0.44; 1"
keySplines="0 0 1 1; 0 0 1 1; 0.35 0 0.35 1; 0 0 1 1"
values="-90 0 -439.094; -90 0 -439.094; -90 0 -439.094; 0 0 -439.094; 0 0 -439.094"
/>
<animate
attributeName="d"
dur="1.5s"
begin="0s"
fill="freeze"
calcMode="spline"
keyTimes="0; 0.4; 0.4681; 0.53; 0.5689; 0.63; 0.68; 0.7944; 1"
keySplines="0 0 1 1; 0.402 0 0.6982 0.4985; 0.2986 0.4514 0.6022 0.9053; 0.2862 0.178 0.6233 0.5935; 0.2988 0.5071 0.6368 1; 0 0 1 1; 0.5175 0 0.6923 0.5; 0.1712 0.5 0.4163 1"
values={animationValues}
/>
{/if}
</path>
</svg>
{:else}
<img
class={cn('aspect-square object-contain', className)}
style:visibility={customLogoLoaded ? 'visible' : 'hidden'}
src={cachedApplicationLogo.getUrl(isLightMode)}
alt={logoAlt}
onload={handleImageLoad}
onerror={handleImageError}
/>
{/if}
@@ -47,6 +47,13 @@ export default class AppConfigService extends APIService {
cachedApplicationLogo.bustCache(light);
};
deleteLogo = async (light = true) => {
await this.api.delete(`/application-images/logo`, {
params: { light }
});
cachedApplicationLogo.bustCache(light);
};
updateEmailLogo = async (emailLogo: File) => {
const formData = new FormData();
formData.append('file', emailLogo);
@@ -37,7 +37,7 @@
? 'translate-x-[108px]'
: ''}"
>
<Logo class="size-10" />
<Logo class="size-10" animate={false} />
</div>
<ConnectArrow
@@ -36,8 +36,8 @@
}
async function updateImages(
logoLight: File | undefined,
logoDark: File | undefined,
logoLight: File | null | undefined,
logoDark: File | null | undefined,
logoEmail: File | undefined,
defaultProfilePicture: File | null | undefined,
backgroundImage: File | null | undefined,
@@ -45,13 +45,19 @@
) {
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
const lightLogoPromise = logoLight
? appConfigService.updateLogo(logoLight, true)
: Promise.resolve();
const lightLogoPromise =
logoLight === null
? appConfigService.deleteLogo(true)
: logoLight
? appConfigService.updateLogo(logoLight, true)
: Promise.resolve();
const darkLogoPromise = logoDark
? appConfigService.updateLogo(logoDark, false)
: Promise.resolve();
const darkLogoPromise =
logoDark === null
? appConfigService.deleteLogo(false)
: logoDark
? appConfigService.updateLogo(logoDark, false)
: Promise.resolve();
const emailLogoPromise = logoEmail
? appConfigService.updateEmailLogo(logoEmail)
@@ -2,8 +2,10 @@
import FileInput from '$lib/components/form/file-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideImageOff, LucideUpload, LucideX } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
@@ -12,6 +14,7 @@
label,
image = $bindable(),
imageURL,
fallback,
accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic',
forceColorScheme,
isResetable = false,
@@ -23,6 +26,7 @@
label: string;
image: File | null | undefined;
imageURL: string;
fallback?: Snippet;
forceColorScheme?: 'light' | 'dark';
accept?: string;
isResetable?: boolean;
@@ -70,7 +74,11 @@
imageClass
)}
>
<LucideImageOff class="text-muted-foreground" />
{#if fallback}
{@render fallback()}
{:else}
<LucideImageOff class="text-muted-foreground" />
{/if}
</div>
{:else}
<img
@@ -97,6 +105,7 @@
{#if isResetable && isImageSet}
<Button
size="icon"
aria-label={`${m.reset_to_default()} ${label}`}
onclick={(e) => {
e.stopPropagation();
onReset();
@@ -1,4 +1,5 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import {
@@ -13,8 +14,8 @@
callback
}: {
callback: (
logoLight: File | undefined,
logoDark: File | undefined,
logoLight: File | null | undefined,
logoDark: File | null | undefined,
logoEmail: File | undefined,
defaultProfilePicture: File | null | undefined,
backgroundImage: File | null | undefined,
@@ -22,8 +23,8 @@
) => void;
} = $props();
let logoLight = $state<File | undefined>();
let logoDark = $state<File | undefined>();
let logoLight = $state<File | null | undefined>();
let logoDark = $state<File | null | undefined>();
let logoEmail = $state<File | undefined>();
let defaultProfilePicture = $state<File | null | undefined>();
let backgroundImage = $state<File | null | undefined>();
@@ -31,8 +32,18 @@
let defaultProfilePictureSet = $state(true);
let backgroundImageSet = $state(true);
let logoLightSet = $state(true);
let logoDarkSet = $state(true);
</script>
{#snippet lightLogoFallback()}
<Logo defaultOnly colorScheme="light" class="size-full" />
{/snippet}
{#snippet darkLogoFallback()}
<Logo defaultOnly colorScheme="dark" class="size-full" />
{/snippet}
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
@@ -48,7 +59,10 @@
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
fallback={lightLogoFallback}
forceColorScheme="light"
isResetable
bind:isImageSet={logoLightSet}
/>
<ApplicationImage
id="logo-dark"
@@ -56,7 +70,10 @@
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}
fallback={darkLogoFallback}
forceColorScheme="dark"
isResetable
bind:isImageSet={logoDarkSet}
/>
<ApplicationImage
id="logo-email"
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ImageBox from '$lib/components/image-box.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -8,7 +9,7 @@
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import {
LucideBan,
@@ -38,13 +39,17 @@
<Card.Content class=" p-0">
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
<ImageBox
class="size-14"
src={client.hasLogo
? cachedOidcClientLogo.getUrl(client.id, isLightMode)
: cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: client.name })}
/>
{#if client.hasLogo}
<ImageBox
class="size-14"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
alt={m.name_logo({ name: client.name })}
/>
{:else}
<div class="bg-muted flex size-14 items-center justify-center rounded-2xl p-3">
<Logo class="size-full" alt={m.name_logo({ name: client.name })} animate={false} />
</div>
{/if}
</div>
<div class="flex w-full justify-between gap-3">
<div class="h-20">