mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-07 10:19:04 +02:00
feat: add ability to search and select icons from selfh.st (#1807)
This commit is contained in:
@@ -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 & {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user