feat: add ability to search and select icons from selfh.st (#1807)

This commit is contained in:
Elias Schneider
2026-10-04 20:40:41 +02:00
committed by GitHub
parent e7d77d8a9d
commit 1be9ba9991
30 changed files with 1506 additions and 247 deletions
+9 -16
View File
@@ -147,7 +147,6 @@
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"username_must_start_with": "Username must start with an alphanumeric character",
"username_must_end_with": "Username must end with an alphanumeric character",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
"or_visit": "or visit",
"added_on": "Added on",
"rename": "Rename",
@@ -301,7 +300,6 @@
"requires_pushed_authorization_requests": "Requires Pushed Authorization Requests",
"requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint to pre-register authorization parameters before initiating the flow.",
"name_logo": "{name} logo",
"upload_logo": "Upload Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
@@ -323,7 +321,6 @@
"cimd_url_allowlist_description": "Restrict which client ID metadata document URLs are accepted. {#link href=|https://pocket-id.org/docs/advanced/callback-url-wildcards|}Wildcards{/link} are supported. An empty list blocks all URLs.",
"refresh": "Refresh",
"oidc_client_metadata_refreshed_successfully": "Client metadata document refreshed successfully",
"generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
@@ -331,9 +328,7 @@
"show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups",
"allowed_user_groups_description": "Select the user groups whose members are allowed to sign in to this client.",
"allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.",
"unrestrict": "Unrestrict",
"restrict": "Restrict",
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
@@ -431,7 +426,6 @@
"userinfo": "Userinfo",
"copy": "Copy",
"copy_all": "Copy All",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
@@ -509,8 +503,6 @@
"configure_application_images": "Configure Application Images",
"ui_config_disabled_info_title": "UI Configuration Disabled",
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.",
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at {#link href=|https://selfh.st/icons|}Selfh.st Icons{/link} or {#link href=|https://dashboardicons.com|}Dashboard Icons{/link}.",
"invalid_url": "Invalid URL",
"invalid_uri": "Invalid URI",
"require_user_email": "Require Email Address",
"require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.",
@@ -581,14 +573,12 @@
"api_permissions_description": "The permissions (scopes) that clients can request for this API.",
"api_permission_key": "Permission",
"add_permission": "Add permission",
"type": "Type",
"api_created_successfully": "API created successfully",
"api_deleted_successfully": "API deleted successfully",
"are_you_sure_you_want_to_delete_this_api": "Are you sure you want to delete this API? Clients will lose access to its permissions.",
"api_access": "API access",
"api_access_description": "Select which APIs this client may request tokens for on behalf of users (user-delegated access) and for itself via the client credentials grant (client access), and which permissions it may ask for.",
"api_access_updated_successfully": "API access updated successfully",
"no_apis_defined_yet": "No APIs have been defined yet. APIs allow clients to request access tokens for specific resources and permissions.",
"access_an_api_on_your_behalf": "Access an API on your behalf",
"api_name": "API Name",
"user_delegated_access": "User-delegated access",
@@ -600,7 +590,6 @@
"client_access_description": "The client may request tokens for this API for itself, without a user.",
"no_access": "No access",
"metadata_document_client_access": "Metadata document clients",
"metadata_document_client_access_description": "Grant access to clients that register themselves through a Client ID Metadata Document (CIMD), such as MCP servers.",
"allow_all_metadata_document_clients": "Allow all metadata document clients",
"allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of users.",
"granted_permissions": "Granted permissions",
@@ -608,8 +597,6 @@
"access_granted_through_cimd_access": "This API allows all metadata document clients, so the access granted that way is shown here but can only be changed on the API itself.",
"access": "Access",
"api_access_card_description": "Choose which clients may request tokens for this API and which permissions they may ask for.",
"api_clients_description": "The clients that were granted access to this API individually.",
"no_clients_have_access_to_this_api": "No client has been granted access to this API yet.",
"add_client": "Add client",
"select_a_client_to_grant_access_to_this_api": "Select the client that should get access to this API.",
"revoke_access_for_name": "Revoke access for {name}",
@@ -654,8 +641,6 @@
"you_have_unsaved_changes": "You have unsaved changes",
"changes_saved_successfully": "Changes saved successfully",
"discard": "Discard",
"discard_unsaved_changes": "Discard unsaved changes?",
"discard_unsaved_changes_confirm": "You have unsaved changes on this page. Are you sure you want to discard them?",
"please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving",
"application": "Application",
"oidc_client_application_description": "How the client is shown to users on the consent screen and the My Apps page.",
@@ -670,7 +655,15 @@
"add_callback_url": "Add callback URL",
"add_logout_url": "Add logout URL",
"remove_logo": "Remove logo",
"use_image_url": "Use an image URL",
"choose_logo": "Choose logo",
"current_logo": "Current logo",
"upload_light_logo": "Upload light logo",
"upload_dark_logo": "Upload dark logo",
"search_icons_or_paste_url": "Search icons or paste an image URL",
"paste_image_url": "Paste an image URL",
"use_as_light_logo": "Use as light logo",
"use_as_dark_logo": "Use as dark logo",
"icons_provided_by_selfhst": "Icons by {#link href=|https://selfh.st/icons|}selfh.st{/link}, licensed under {#link href=|https://creativecommons.org/licenses/by/4.0/|}CC BY 4.0{/link}.",
"claims": "Claims",
"set_custom_client_id": "Set custom client ID",
"client_type": "Client type",
+10 -2
View File
@@ -8,6 +8,7 @@ import type {
OidcClient,
OidcClientCreate,
OidcClientCreated,
OidcClientLogoPreset,
OidcClientMetaData,
OidcClientSecret,
OidcClientSecretCreated,
@@ -81,14 +82,21 @@ class OidcService extends APIService {
await this.api.post(`/oidc/clients/${encodeClientIdParam(client.id)}/logo`, formData, {
params: { light }
});
cachedOidcClientLogo.bustCache(client.id, light);
cachedOidcClientLogo.bustCache(client.id);
};
removeClientLogo = async (id: string, light: boolean = true) => {
await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}/logo`, {
params: { light }
});
cachedOidcClientLogo.bustCache(id, light);
cachedOidcClientLogo.bustCache(id);
};
searchLogoPresets = async (search: string) => {
const { data } = await this.api.get<OidcClientLogoPreset[]>('/oidc/logo-presets', {
params: { search }
});
return data;
};
listClientSecrets = async (id: string) =>
@@ -14,6 +14,7 @@ export type AppConfig = {
accentColor: string;
requireUserEmail: boolean;
tracingEnabled: boolean;
iconLibrary: 'default' | 'custom' | 'disabled';
};
export type AllAppConfig = AppConfig & {
+7
View File
@@ -104,6 +104,13 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
darkLogoUrl?: string;
};
export type OidcClientLogoPreset = {
name: string;
reference: string;
logoUrl: string;
darkLogoUrl: string | null;
};
export type OidcDeviceCodeInfo = {
scope: string[];
scopeInfo: InteractionScopeInfo[];
+14 -15
View File
@@ -57,24 +57,23 @@ export const cachedProfilePicture: CachableImage = {
};
export const cachedOidcClientLogo: CachableImage = {
getUrl: (clientId: string, light = true) => {
const url = new URL(
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
window.location.origin
);
if (!light) url.searchParams.set('light', 'false');
return getCachedImageUrl(url);
},
bustCache: (clientId: string, light = true) => {
const url = new URL(
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
window.location.origin
);
if (!light) url.searchParams.set('light', 'false');
bustImageCache(url);
getUrl: (clientId: string, light = true) => getCachedImageUrl(oidcClientLogoUrl(clientId, light)),
// Each variant falls back to the other one, so changing either logo can change what both URLs serve
bustCache: (clientId: string) => {
bustImageCache(oidcClientLogoUrl(clientId, true));
bustImageCache(oidcClientLogoUrl(clientId, false));
}
};
function oidcClientLogoUrl(clientId: string, light: boolean) {
const url = new URL(
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
window.location.origin
);
if (!light) url.searchParams.set('light', 'false');
return url;
}
// Builds the URL of an authenticator icon
// Unlike the images above these are static assets that only change with a new release, so they skip the cache busting helper and rely on the backend's cache headers
export function authenticatorIconUrl(aaguid: string, light = true) {
@@ -122,7 +122,7 @@
></div>
<div class="client relative size-10" class:faded={result}>
{#if client?.hasLogo}
{#if client?.hasLogo || client?.hasDarkLogo}
<img
class="aspect-square size-10 object-contain"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
@@ -105,7 +105,11 @@
{#snippet ClientCell({ item }: { item: ClientRow })}
<div class="flex items-center gap-3">
<OidcClientAvatar id={item.client.id} name={item.client.name} hasLogo={item.client.hasLogo} />
<OidcClientAvatar
id={item.client.id}
name={item.client.name}
hasLogo={item.client.hasLogo || item.client.hasDarkLogo}
/>
<div class="flex flex-col gap-0.5">
<a
class="font-medium hover:underline"
@@ -38,7 +38,7 @@
</script>
{#snippet LogoCell({ item }: { item: ApiClient })}
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo} />
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo || item.hasDarkLogo} />
{/snippet}
<Dialog.Root bind:open>
@@ -67,11 +67,8 @@
const [savedClient] = await Promise.all([dataPromise, imagePromise, darkImagePromise]);
Object.assign(client, savedClient);
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}
if (updatedClient.darkLogoUrl) {
cachedOidcClientLogo.bustCache(client.id, false);
if (updatedClient.logoUrl || updatedClient.darkLogoUrl) {
cachedOidcClientLogo.bustCache(client.id);
}
// Update the hasLogo and hasDarkLogo flags after successful upload
@@ -4,19 +4,22 @@
import * as Card from '#lib/components/ui/card/index.ts';
import * as Field from '#lib/components/ui/field/index.ts';
import { Switch } from '#lib/components/ui/switch/index.ts';
import * as Tabs from '#lib/components/ui/tabs/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { OidcClient, OidcClientCreateWithLogo } from '#lib/types/oidc.type.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type {
OidcClient,
OidcClientCreateWithLogo,
OidcClientLogoPreset
} from '#lib/types/oidc.type.ts';
import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { createForm, type FormInput as FormInputState } from '#lib/utils/form-util.ts';
import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts';
import { callbackUrlSchema, optionalUrl } from '#lib/utils/zod-util.ts';
import { LucideMoon, LucideSun } from '@lucide/svelte';
import { z } from 'zod/v4';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import OidcClientImageInput from './oidc-client-image-input.svelte';
import OidcClientLogoPicker from './oidc-client-logo-picker.svelte';
let {
callback,
@@ -152,6 +155,16 @@
}
}
// A preset fills both variants, and clears the dark logo when the icon has no dark variant so a stale one isn't kept
function selectPreset(preset: OidcClientLogoPreset) {
onLogoChange(preset.logoUrl, true);
if (preset.darkLogoUrl) {
onLogoChange(preset.darkLogoUrl, false);
} else {
resetLogo(false);
}
}
function resetLogo(light: boolean = true) {
if (light) {
logo = null;
@@ -287,40 +300,19 @@
/>
{/snippet}
{#snippet logoTabTriggers()}
<Tabs.List class="grid h-8 grid-cols-2">
<Tabs.Trigger value="light-logo" class="px-2.5" aria-label={m.light()}>
<LucideSun class="size-3.5" />
</Tabs.Trigger>
<Tabs.Trigger value="dark-logo" class="px-2.5" aria-label={m.dark()}>
<LucideMoon class="size-3.5" />
</Tabs.Trigger>
</Tabs.List>
{/snippet}
{#snippet logoInput()}
<Tabs.Root value="light-logo">
<Tabs.Content value="light-logo">
<OidcClientImageInput
{logoDataURL}
resetLogo={() => resetLogo(true)}
clientName={$inputs.name.value}
light={true}
onLogoChange={(input) => onLogoChange(input, true)}
tabTriggers={logoTabTriggers}
/>
</Tabs.Content>
<Tabs.Content value="dark-logo">
<OidcClientImageInput
light={false}
logoDataURL={darkLogoDataURL}
resetLogo={() => resetLogo(false)}
clientName={$inputs.name.value}
onLogoChange={(input) => onLogoChange(input, false)}
tabTriggers={logoTabTriggers}
/>
</Tabs.Content>
</Tabs.Root>
<OidcClientLogoPicker
clientName={$inputs.name.value}
{logoDataURL}
{darkLogoDataURL}
iconLibrary={$appConfigStore.iconLibrary}
{onLogoChange}
onPresetSelect={selectPreset}
onReset={() => {
resetLogo(true);
resetLogo(false);
}}
/>
{/snippet}
<!-- The form is split into cards so that related settings are grouped together -->
@@ -1,147 +0,0 @@
<script lang="ts">
import FileInput from '#lib/components/form/file-input.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import ImageBox from '#lib/components/image-box.svelte';
import { Button, buttonVariants } from '#lib/components/ui/button/index.ts';
import { Input } from '#lib/components/ui/input/index.ts';
import { Label } from '#lib/components/ui/label/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideLink, LucideUpload, LucideX } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let {
logoDataURL,
clientName,
resetLogo,
onLogoChange,
light,
tabTriggers
}: {
logoDataURL: string | null;
clientName: string;
resetLogo: () => void;
onLogoChange: (file: File | string | null) => void;
tabTriggers?: Snippet;
light: boolean;
} = $props();
const id = `oidc-client-logo-${light ? 'light' : 'dark'}`;
let url = $state('');
let hasUrlError = $state(false);
let isDraggingOver = $state(false);
function selectFile(file: File | null) {
url = '';
hasUrlError = false;
onLogoChange(file);
}
function onFileChange(e: Event) {
selectFile((e.target as HTMLInputElement).files?.[0] || null);
}
// Dropping an image on the preview behaves like picking it in the file dialog
function onDrop(e: DragEvent) {
e.preventDefault();
isDraggingOver = false;
const file = e.dataTransfer?.files[0];
if (file?.type.startsWith('image/')) {
selectFile(file);
}
}
function onUrlChange(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (!value) return;
try {
new URL(value);
hasUrlError = false;
} catch {
hasUrlError = true;
return;
}
onLogoChange(value);
}
</script>
<div class="flex w-28 flex-col gap-2">
<div
class="relative"
role="group"
aria-label={m.logo()}
ondragover={(e) => {
e.preventDefault();
isDraggingOver = true;
}}
ondragleave={() => (isDraggingOver = false)}
ondrop={onDrop}
>
<FileInput
{id}
accept="image/*"
onchange={onFileChange}
onclick={(e: any) => (e.target.value = '')}
class={cn(
'text-muted-foreground hover:bg-muted/50 flex size-28 cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-2xl border border-dashed text-xs transition-colors',
logoDataURL && (light ? 'bg-[#F5F5F5]' : 'bg-[#262626]'),
isDraggingOver && 'border-primary bg-muted/50'
)}
>
{#if logoDataURL}
<ImageBox
class="size-full bg-transparent"
src={logoDataURL}
alt={m.name_logo({ name: clientName })}
/>
<span class="sr-only">{m.upload_logo()}</span>
{:else}
<LucideUpload class="size-5" />
{m.logo()}
{/if}
</FileInput>
{#if logoDataURL}
<Button
size="icon"
onclick={resetLogo}
aria-label={m.remove_logo()}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
>
<LucideX class="size-3" />
</Button>
{/if}
</div>
<div class="flex items-center justify-between gap-1">
{@render tabTriggers?.()}
<Popover.Root>
<Popover.Trigger
class={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'text-muted-foreground')}
aria-label={m.use_image_url()}
>
<LucideLink class="size-4" />
</Popover.Trigger>
<Popover.Content class="w-80">
<Label for="{id}-url" class="text-xs">URL</Label>
<Input
id="{id}-url"
value={url}
oninput={(e) => (url = e.currentTarget.value)}
onfocusout={onUrlChange}
aria-invalid={hasUrlError}
type="url"
/>
{#if hasUrlError}
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
{/if}
<p class="text-muted-foreground mt-2 text-xs">
<FormattedMessage message={m.logo_from_url_description} />
</p>
</Popover.Content>
</Popover.Root>
</div>
</div>
@@ -176,7 +176,7 @@
{/snippet}
{#snippet LogoCell({ item }: { item: OidcClientWithAllowedGroups })}
{#if item.hasLogo}
{#if item.hasLogo || item.hasDarkLogo}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
@@ -0,0 +1,356 @@
<script lang="ts">
import FormattedMessage from '#lib/components/formatted-message.svelte';
import ImageBox from '#lib/components/image-box.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Command from '#lib/components/ui/command/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { m } from '#lib/paraglide/messages.js';
import OidcService from '#lib/services/oidc-service.ts';
import type { AppConfig } from '#lib/types/application-configuration.type.ts';
import type { OidcClientLogoPreset } from '#lib/types/oidc.type.ts';
import { debounced } from '#lib/utils/debounce-util.ts';
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideMoon, LucideSun, LucideUpload, LucideX } from '@lucide/svelte';
import { Command as CommandPrimitive } from 'bits-ui';
import { mode } from 'mode-watcher';
let {
clientName,
logoDataURL,
darkLogoDataURL,
iconLibrary,
onLogoChange,
onPresetSelect,
onReset
}: {
clientName: string;
logoDataURL: string | null;
darkLogoDataURL: string | null;
iconLibrary: AppConfig['iconLibrary'];
onLogoChange: (input: File | string, light: boolean) => void;
onPresetSelect: (preset: OidcClientLogoPreset) => void;
onReset: () => void;
} = $props();
const oidcService = new OidcService();
const popoverOffset = 4;
const viewportMargin = 16;
const lightBackground = 'bg-[#F5F5F5]';
const darkBackground = 'bg-[#262626]';
let open = $state(false);
let alignOffset = $state(0);
let triggerRef = $state<HTMLElement | null>(null);
let contentRef = $state<HTMLElement | null>(null);
let lightFileInput = $state<HTMLInputElement | null>(null);
let darkFileInput = $state<HTMLInputElement | null>(null);
let search = $state('');
let presets = $state<OidcClientLogoPreset[]>([]);
let errorMessage = $state<string | null>(null);
let latestRequest = 0;
const isLightMode = $derived(mode.current === 'light');
const presetsEnabled = $derived(iconLibrary !== 'disabled');
const imageUrl = $derived(parseImageUrl(search));
const showPresets = $derived(presetsEnabled && !imageUrl);
const showIconGrid = $derived(showPresets && presets.length > 0);
const effectiveLightLogoURL = $derived(logoDataURL ?? darkLogoDataURL);
const effectiveDarkLogoURL = $derived(darkLogoDataURL ?? logoDataURL);
const previewURL = $derived(isLightMode ? effectiveLightLogoURL : effectiveDarkLogoURL);
const hasLogo = $derived(!!(logoDataURL || darkLogoDataURL));
// Input that parses as an absolute HTTP(S) URL is used as the image itself instead of as a search term
function parseImageUrl(value: string): string | null {
const trimmed = value.trim();
try {
const url = new URL(trimmed);
return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null;
} catch {
return null;
}
}
async function loadPresets(query: string) {
const request = ++latestRequest;
try {
const result = await oidcService.searchLogoPresets(query);
// Responses can arrive out of order, so only the newest search may update the results
if (request === latestRequest) {
presets = result;
errorMessage = null;
}
} catch (e) {
if (request === latestRequest) {
presets = [];
errorMessage = getAxiosErrorMessage(e);
}
}
}
// The popover always opens below the logo without collision handling, so it is fitted into the viewport by hand whenever its size or the window changes
$effect(() => {
if (!open || !triggerRef || !contentRef) return;
const trigger = triggerRef;
const content = contentRef;
const fit = () => fitPopoverIntoViewport(trigger, content);
const observer = new ResizeObserver(fit);
observer.observe(content);
window.addEventListener('resize', fit);
return () => {
observer.disconnect();
window.removeEventListener('resize', fit);
};
});
// Measured from the logo because the popover itself is still animating in
function fitPopoverIntoViewport(trigger: HTMLElement, content: HTMLElement) {
const triggerRect = trigger.getBoundingClientRect();
// Shift the popover left when it would stick out on the right, which happens on narrow screens
const overflowRight =
triggerRect.left + content.offsetWidth + viewportMargin - window.innerWidth;
alignOffset = -Math.max(0, overflowRight);
// Scroll the page just far enough to show all of the popover
// The scroll is instant because a smooth one gets cancelled when the results arrive and the first icon is scrolled into view
const overflowBottom =
triggerRect.bottom +
popoverOffset +
content.offsetHeight +
viewportMargin -
window.innerHeight;
if (overflowBottom > 0) {
window.scrollBy({ top: overflowBottom, behavior: 'instant' });
}
}
// A search that was queued while typing is skipped once the input has changed since, or if it is a URL
const onSearch = debounced((query: string) => {
if (!presetsEnabled || query !== search || parseImageUrl(query)) return;
return loadPresets(query);
}, 250);
function resetSearch() {
search = '';
if (presetsEnabled) {
loadPresets(search);
}
}
function onOpenChange(isOpen: boolean) {
if (!isOpen) return;
// Results of the previous search would otherwise show until the new one finishes
presets = [];
errorMessage = null;
resetSearch();
}
function selectPreset(preset: OidcClientLogoPreset) {
open = false;
onPresetSelect(preset);
}
// The picker stays open after a custom image is added, so the current logo tile shows both variants and the other one can be added too
function selectUrl(url: string, light: boolean) {
onLogoChange(url, light);
resetSearch();
}
// Pressing enter on a pasted URL uses it as the light logo, which dark mode falls back to
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && imageUrl) {
e.preventDefault();
selectUrl(imageUrl, true);
}
}
function onFileChange(e: Event, light: boolean) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
// Clearing the input lets the same file be picked again after it was removed
input.value = '';
if (!file) return;
onLogoChange(file, light);
}
</script>
<!-- The file inputs live outside the popover so a picked file is still handled after it closes -->
<input
id="oidc-client-logo-light"
bind:this={lightFileInput}
type="file"
accept="image/*"
class="hidden"
onchange={(e) => onFileChange(e, true)}
/>
<input
id="oidc-client-logo-dark"
bind:this={darkFileInput}
type="file"
accept="image/*"
class="hidden"
onchange={(e) => onFileChange(e, false)}
/>
{#snippet tileHalf(src: string | null, light: boolean)}
<span
class={cn('flex items-center justify-center p-2', light ? lightBackground : darkBackground)}
>
{#if src}
<img {src} alt="" class="size-7 object-contain" loading="lazy" />
{:else if light}
<LucideSun class="size-4 text-neutral-400" />
{:else}
<LucideMoon class="size-4 text-neutral-500" />
{/if}
</span>
{/snippet}
{#snippet uploadHalf(light: boolean)}
<button
type="button"
class={cn(
'flex flex-col items-center justify-center gap-1 text-xs font-medium transition-opacity hover:opacity-80',
light ? `${lightBackground} text-neutral-600` : `${darkBackground} text-neutral-300`
)}
aria-label={light ? m.upload_light_logo() : m.upload_dark_logo()}
onclick={() => (light ? lightFileInput : darkFileInput)?.click()}
>
<LucideUpload class="size-4" />
{light ? m.light() : m.dark()}
</button>
{/snippet}
{#snippet urlHalf(url: string, light: boolean)}
<button
type="button"
class={cn(
'flex flex-col items-center justify-center gap-1 text-[10px] font-medium transition-opacity hover:opacity-80',
light ? `${lightBackground} text-neutral-600` : `${darkBackground} text-neutral-300`
)}
aria-label={light ? m.use_as_light_logo() : m.use_as_dark_logo()}
title={light ? m.use_as_light_logo() : m.use_as_dark_logo()}
onclick={() => selectUrl(url, light)}
>
<img src={url} alt="" class="size-6 object-contain" />
{light ? m.light() : m.dark()}
</button>
{/snippet}
<div class="relative w-28">
<Popover.Root bind:open {onOpenChange}>
<Popover.Trigger
bind:ref={triggerRef}
aria-label={m.choose_logo()}
class={cn(
'text-muted-foreground flex size-28 cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-2xl border text-xs transition-opacity hover:opacity-90',
previewURL ? (isLightMode ? lightBackground : darkBackground) : 'border-dashed'
)}
>
{#if previewURL}
<ImageBox
class="size-full bg-transparent"
src={previewURL}
alt={m.name_logo({ name: clientName })}
/>
{:else}
<LucideUpload class="size-5" />
{m.logo()}
{/if}
</Popover.Trigger>
<Popover.Content
bind:ref={contentRef}
class="w-[min(24rem,calc(100vw-2rem))] gap-0 overflow-hidden p-0"
align="start"
sideOffset={popoverOffset}
{alignOffset}
avoidCollisions={false}
>
<!-- The popover isn't positioned yet when the first icon gets selected, so scrolling it into view would scroll the page instead -->
<Command.Root shouldFilter={false} columns={3} disableInitialScroll class="rounded-none">
<Command.Input
placeholder={presetsEnabled ? m.search_icons_or_paste_url() : m.paste_image_url()}
aria-label={presetsEnabled ? m.search_icons_or_paste_url() : m.paste_image_url()}
bind:value={search}
oninput={(e) => onSearch(e.currentTarget.value)}
onkeydown={onInputKeydown}
/>
<!-- Without icons the list shrinks to fit the remaining tiles -->
<Command.List class={cn('px-2 pt-4 pb-2', showIconGrid && 'h-80')}>
<div class="grid grid-cols-3 gap-2">
<!-- The first tile adds a custom image, either from a pasted URL or as an upload -->
<div class="grid h-14 grid-cols-2 overflow-hidden rounded-xl">
{#if imageUrl}
{@render urlHalf(imageUrl, true)}
{@render urlHalf(imageUrl, false)}
{:else}
{@render uploadHalf(true)}
{@render uploadHalf(false)}
{/if}
</div>
{#if !imageUrl}
<!-- The current logo gets its own tile, even before it is saved, because a picked icon isn't necessarily in the search results -->
<!-- As the first item of the list it is what the picker selects, so it shows which logo is in use -->
{#if hasLogo}
<CommandPrimitive.Item
value="custom"
onSelect={() => (open = false)}
aria-label={m.current_logo()}
title={m.current_logo()}
class="data-selected:ring-primary grid h-14 cursor-pointer grid-cols-2 overflow-hidden rounded-xl outline-hidden select-none data-selected:ring-2"
>
{@render tileHalf(effectiveLightLogoURL, true)}
{@render tileHalf(effectiveDarkLogoURL, false)}
</CommandPrimitive.Item>
{/if}
{#if presetsEnabled}
{#each presets as preset (preset.reference)}
<CommandPrimitive.Item
value={preset.reference}
onSelect={() => selectPreset(preset)}
aria-label={preset.name}
title={preset.name}
class="data-selected:ring-primary grid h-14 cursor-pointer grid-cols-2 overflow-hidden rounded-xl outline-hidden select-none data-selected:ring-2"
>
{@render tileHalf(preset.logoUrl, true)}
{@render tileHalf(preset.darkLogoUrl ?? preset.logoUrl, false)}
</CommandPrimitive.Item>
{/each}
{/if}
{/if}
</div>
{#if showPresets && presets.length === 0}
<div class="text-muted-foreground flex justify-center px-3 py-6 text-center text-sm">
{#if errorMessage}
{errorMessage}
{/if}
</div>
{/if}
</Command.List>
</Command.Root>
<!-- A custom icon library isn't necessarily selfh.st's collection, so the credit only applies to the default one while its icons are shown -->
{#if iconLibrary === 'default' && showIconGrid}
<p class="text-muted-foreground border-t px-3 py-2 text-[11px]">
<FormattedMessage message={m.icons_provided_by_selfhst} />
</p>
{/if}
</Popover.Content>
</Popover.Root>
{#if hasLogo}
<Button
size="icon"
onclick={onReset}
aria-label={m.remove_logo()}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
>
<LucideX class="size-3" />
</Button>
{/if}
</div>
@@ -72,7 +72,7 @@
</script>
{#snippet LogoCell({ item }: { item: OidcClient })}
{#if item.hasLogo}
{#if item.hasLogo || item.hasDarkLogo}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
@@ -40,7 +40,7 @@
<Card.Content class="p-0">
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
{#if client.hasLogo}
{#if client.hasLogo || client.hasDarkLogo}
<ImageBox
class="size-14"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}