mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-15 01:10:17 +00:00
feat: ui accent colors (#643)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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