mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-27 17:56:36 +00:00
feat: ui accent colors (#643)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
39
frontend/src/lib/components/form/switch-with-label.svelte
Normal file
39
frontend/src/lib/components/form/switch-with-label.svelte
Normal 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>
|
||||
10
frontend/src/lib/components/ui/radio-group/index.ts
Normal file
10
frontend/src/lib/components/ui/radio-group/index.ts
Normal 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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
7
frontend/src/lib/components/ui/switch/index.ts
Normal file
7
frontend/src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from './switch.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch
|
||||
};
|
||||
29
frontend/src/lib/components/ui/switch/switch.svelte
Normal file
29
frontend/src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export type AppConfig = {
|
||||
ldapEnabled: boolean;
|
||||
disableAnimations: boolean;
|
||||
uiConfigDisabled: boolean;
|
||||
accentColor: string;
|
||||
};
|
||||
|
||||
export type AllAppConfig = AppConfig & {
|
||||
|
||||
58
frontend/src/lib/utils/accent-color-util.ts
Normal file
58
frontend/src/lib/utils/accent-color-util.ts
Normal 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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="pt-6">
|
||||
<Card.Content>
|
||||
<ProfilePictureSettings
|
||||
userId={user.id}
|
||||
isLdapUser={!!user.ldapId}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user