feat: ui accent colors (#643)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-13 07:06:54 -05:00
committed by GitHub
parent 215531d65c
commit 883877adec
24 changed files with 525 additions and 236 deletions

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch/index.js';
let {
id,
checked = $bindable(),
label,
description,
disabled = false,
onCheckedChange
}: {
id: string;
checked: boolean;
label: string;
description?: string;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
} = $props();
</script>
<div class="items-top flex space-x-2">
<Switch
{id}
{disabled}
onCheckedChange={(v) => onCheckedChange && onCheckedChange(v == true)}
bind:checked
/>
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm leading-none font-medium">
{label}
</Label>
{#if description}
<p class="text-muted-foreground text-[0.8rem]">
{description}
</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,10 @@
import Root from './radio-group.svelte';
import Item from './radio-group-item.svelte';
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn('grid gap-3', className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from './switch.svelte';
export {
Root,
//
Root as Switch
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>

View File

@@ -1,5 +1,6 @@
import AppConfigService from '$lib/services/app-config-service';
import type { AppConfig } from '$lib/types/application-configuration';
import { applyAccentColor } from '$lib/utils/accent-color-util';
import { writable } from 'svelte/store';
const appConfigStore = writable<AppConfig>();
@@ -8,10 +9,11 @@ const appConfigService = new AppConfigService();
const reload = async () => {
const appConfig = await appConfigService.list();
appConfigStore.set(appConfig);
set(appConfig);
};
const set = (appConfig: AppConfig) => {
applyAccentColor(appConfig.accentColor);
appConfigStore.set(appConfig);
};

View File

@@ -6,6 +6,7 @@ export type AppConfig = {
ldapEnabled: boolean;
disableAnimations: boolean;
uiConfigDisabled: boolean;
accentColor: string;
};
export type AllAppConfig = AppConfig & {

View File

@@ -0,0 +1,58 @@
export function applyAccentColor(accentValue: string) {
if (accentValue === 'default') {
document.documentElement.style.removeProperty('--primary');
document.documentElement.style.removeProperty('--primary-foreground');
document.documentElement.style.removeProperty('--ring');
document.documentElement.style.removeProperty('--sidebar-ring');
return;
}
document.documentElement.style.setProperty('--primary', accentValue);
// Smart foreground color selection based on brightness
const foregroundColor = getContrastingForeground(accentValue);
document.documentElement.style.setProperty('--primary-foreground', foregroundColor);
// Create proper ring colors based on input format
const ringColor = `color-mix(in srgb, ${accentValue} 50%, transparent)`;
document.documentElement.style.setProperty('--ring', ringColor);
document.documentElement.style.setProperty('--sidebar-ring', ringColor);
}
function getContrastingForeground(color: string): string {
const brightness = getColorBrightness(color);
// Use white text for dark colors, black text for light colors
return brightness < 0.55 ? 'oklch(0.98 0 0)' : 'oklch(0.09 0 0)';
}
function getColorBrightness(color: string): number {
// Create a temporary element to get computed color
const tempElement = document.createElement('div');
tempElement.style.color = color;
document.body.appendChild(tempElement);
const computedColor = window.getComputedStyle(tempElement).color;
document.body.removeChild(tempElement);
// Parse RGB values from computed color
const rgbMatch = computedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!rgbMatch) {
// Fallback: assume medium brightness
return 0.5;
}
const [, r, g, b] = rgbMatch.map(Number);
// Calculate relative luminance using the standard formula
// https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
const sR = r / 255;
const sG = g / 255;
const sB = b / 255;
const rLinear = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
const gLinear = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
const bLinear = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);
return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
}

View File

@@ -44,6 +44,17 @@
<Header />
{@render children()}
{/if}
<Toaster />
<Toaster
toastOptions={{
classes: {
toast: 'border border-primary/30!',
title: 'text-foreground',
description: 'text-muted-foreground',
actionButton: 'bg-primary text-primary-foreground hover:bg-primary/90',
cancelButton: 'bg-muted text-muted-foreground hover:bg-muted/80',
closeButton: 'text-muted-foreground hover:text-foreground'
}
}}
/>
<ConfirmDialog />
<ModeWatcher />

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { applyAccentColor } from '$lib/utils/accent-color-util';
import { Check, Plus } from '@lucide/svelte';
import CustomColorDialog from './custom-accent-color-dialog.svelte';
let {
selectedColor = $bindable(),
previousColor
}: { selectedColor: string; previousColor: string } = $props();
let showCustomColorDialog = $state(false);
const accentColors = [
{ label: 'Default', color: 'default' },
{ label: 'Rose', color: 'oklch(0.63 0.2 15)' },
{ label: 'Orange', color: 'oklch(0.68 0.2 50)' },
{ label: 'Amber', color: 'oklch(0.75 0.18 80)' },
{ label: 'Green', color: 'oklch(0.65 0.2 150)' },
{ label: 'Teal', color: 'oklch(0.6 0.15 180)' },
{ label: 'Blue', color: 'oklch(0.6 0.2 240)' },
{ label: 'Purple', color: 'oklch(0.6 0.24 300)' }
];
// Check if current accent color is a custom color (not in predefined list)
let isCustomColor = $derived(!accentColors.some((c) => c.color === selectedColor));
let isPreviousColorCustom = $derived(!accentColors.some((c) => c.color === previousColor));
function handleAccentColorChange(accentValue: string) {
selectedColor = accentValue;
applyAccentColor(accentValue);
}
function handleCustomColorApply(color: string) {
handleAccentColorChange(color);
}
</script>
<RadioGroup.Root
class="flex flex-wrap gap-3"
value={isCustomColor ? 'custom' : selectedColor}
onValueChange={(value) => {
if (value != 'custom') {
handleAccentColorChange(value);
}
}}
>
{#each accentColors as accent}
{@render colorOption(accent.label, accent.color, selectedColor === accent.color)}
{/each}
{#if isCustomColor || isPreviousColorCustom}
{@render colorOption('Custom', isCustomColor ? selectedColor : previousColor, isCustomColor)}
{/if}
{@render colorOption('Custom', 'custom', false, true)}
</RadioGroup.Root>
<CustomColorDialog bind:open={showCustomColorDialog} onApply={handleCustomColorApply} />
{#snippet colorOption(
label: string,
color: string,
isSelected: boolean,
isCustomColorSelection = false
)}
<div class="group/item relative">
<RadioGroup.Item id={color} value={color} class="sr-only" />
<Label
for={color}
class="cursor-pointer {isCustomColorSelection ? 'group' : ''}"
onclick={() => {
if (isCustomColorSelection) {
showCustomColorDialog = true;
}
}}
>
<div
class={{
'relative z-10 size-8 rounded-full border-2 transition-all duration-200 ease-out group-hover/item:z-20 group-hover/item:scale-110': true,
'bg-black dark:bg-white': color === 'default'
}}
style={color !== 'default' ? `background-color: ${color}` : ''}
title={label}
>
{#if isCustomColorSelection}
<div
class="bg-muted absolute inset-0 flex items-center justify-center rounded-full border-2 border-dashed border-gray-300"
>
<Plus class="text-muted-foreground size-4" />
</div>
{:else if isSelected}
<div class="absolute inset-0 flex items-center justify-center">
<Check class="size-4 text-white drop-shadow-sm" />
</div>
{/if}
</div>
<div
class="text-muted-foreground group-hover/item:text-foreground bg-background absolute top-12 left-1/2 z-20 max-w-0 -translate-x-1/2 transform overflow-hidden rounded-md border px-2 py-1 text-xs whitespace-nowrap opacity-0 shadow-sm transition-all duration-300 ease-out group-hover/item:max-w-[100px] group-hover/item:opacity-100"
>
{label}
</div>
</Label>
</div>
{/snippet}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
@@ -121,7 +121,7 @@
</Select.Content>
</Select.Root>
</div>
<CheckboxWithLabel
<SwitchWithLabel
id="skip-cert-verify"
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
@@ -130,26 +130,26 @@
</div>
<h4 class="mt-10 text-lg font-semibold">{m.enabled_emails()}</h4>
<div class="mt-4 flex flex-col gap-5">
<CheckboxWithLabel
<SwitchWithLabel
id="email-login-notification"
label={m.email_login_notification()}
description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="email-login-admin"
label={m.email_login_code_from_admin()}
description={m.allows_an_admin_to_send_a_login_code_to_the_user()}
bind:checked={$inputs.emailOneTimeAccessAsAdminEnabled.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="api-key-expiration"
label={m.api_key_expiration()}
description={m.send_an_email_to_the_user_when_their_api_key_is_about_to_expire()}
bind:checked={$inputs.emailApiKeyExpirationEnabled.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="email-login-user"
label={m.emai_login_code_requested_by_user()}
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
@@ -9,6 +10,7 @@
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
import AccentColorPicker from './accent-color-picker.svelte';
let {
callback,
@@ -25,7 +27,8 @@
sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
disableAnimations: appConfig.disableAnimations
disableAnimations: appConfig.disableAnimations,
accentColor: appConfig.accentColor
};
const formSchema = z.object({
@@ -33,14 +36,17 @@
sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean(),
disableAnimations: z.boolean()
disableAnimations: z.boolean(),
accentColor: z.string()
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
toast.success(m.application_configuration_updated_successfully());
}
@@ -56,24 +62,40 @@
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="emails-verified"
label={m.emails_verified()}
description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()}
bind:checked={$inputs.emailsVerified.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_ui_animations()}
bind:checked={$inputs.disableAnimations.value}
/>
<div class="space-y-5">
<div>
<Label class="mb-0 text-sm font-medium">
{m.accent_color()}
</Label>
<p class="text-muted-foreground text-[0.8rem]">
{m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()}
</p>
</div>
<AccentColorPicker
previousColor={appConfig.accentColor}
bind:selectedColor={$inputs.accentColor.value}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
@@ -140,13 +140,13 @@
placeholder="(objectClass=groupOfNames)"
bind:input={$inputs.ldapUserGroupSearchFilter}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="skip-cert-verify"
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.ldapSkipCertVerify.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="ldap-soft-delete-users"
label={m.ldap_soft_delete_users()}
description={m.ldap_soft_delete_users_description()}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { m } from '$lib/paraglide/messages';
import { preventDefault } from '$lib/utils/event-util';
let {
open = $bindable(false),
onApply
}: {
open: boolean;
onApply: (color: string) => void;
} = $props();
let customColorInput = $state('');
function applyCustomColor() {
if (!isValidColor(customColorInput)) return;
onApply(customColorInput);
open = false;
}
function isValidColor(color: string): boolean {
// Create a temporary element to test if the color is valid
const testElement = document.createElement('div');
testElement.style.color = color;
return testElement.style.color !== '';
}
function onOpenChange(newOpen: boolean) {
if (!newOpen) {
customColorInput = '';
}
open = newOpen;
}
</script>
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">{m.custom_accent_color()}</Dialog.Title>
<Dialog.Description>
{m.custom_accent_color_description()}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={preventDefault(applyCustomColor)}>
<div class="space-y-4">
<div>
<Label for="custom-color-input" class="text-sm font-medium">{m.color_value()}</Label>
<div class="flex items-center gap-2">
<div class="w-full transition">
<Input
id="custom-color-input"
bind:value={customColorInput}
placeholder="#3b82f6"
class="mt-1 flex-1"
/>
</div>
<div
class={{
'border-border mt-1 rounded-lg border-1 transition-all duration-200 ease-in-out': true,
'h-9 w-9': isValidColor(customColorInput),
'h-0 w-0': !isValidColor(customColorInput)
}}
style="background-color: {customColorInput}"
></div>
</div>
</div>
</div>
<Dialog.Footer class="mt-6">
<Button variant="secondary" onclick={() => onOpenChange(false)}>{m.cancel()}</Button>
<Button type="submit" disabled={!customColorInput || !isValidColor(customColorInput)}
>{m.apply()}</Button
>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import FileInput from '$lib/components/form/file-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
@@ -120,13 +120,13 @@
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="public-client"
label={m.public_client()}
description={m.public_clients_description()}
bind:checked={$inputs.isPublic.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="pkce"
label={m.pkce()}
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}

View File

@@ -98,7 +98,7 @@
</Card.Root>
<Card.Root>
<Card.Content class="pt-6">
<Card.Content>
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
@@ -62,13 +62,13 @@
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} />
<CheckboxWithLabel
<SwitchWithLabel
id="admin-privileges"
label={m.admin_privileges()}
description={m.admins_have_full_access_to_the_admin_panel()}
bind:checked={$inputs.isAdmin.value}
/>
<CheckboxWithLabel
<SwitchWithLabel
id="user-disabled"
label={m.user_disabled()}
description={m.disabled_users_cannot_log_in_or_use_services()}