1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-15 02:55:14 +00:00

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

@@ -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()}