feat(shared-ui): add reusable settings components with glass styling

- Add SettingsPage, SettingsSection, SettingsCard components
- Add SettingsRow, SettingsToggle for interactive elements
- Add SettingsDangerZone, SettingsDangerButton for destructive actions
- Apply glass morphism styling matching PillNavigation
- Migrate settings pages in manacore, presi, zitare apps
- Migrate archived apps: maerchenzauber, memoro, nutriphi, uload

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 13:22:12 +01:00
parent 3cfa6a765a
commit 7deb5b9a1e
16 changed files with 2391 additions and 1222 deletions

View file

@ -47,3 +47,14 @@ export type {
PillNavElement,
PillNavigationProps,
} from './navigation';
// Settings
export {
SettingsPage,
SettingsSection,
SettingsCard,
SettingsRow,
SettingsToggle,
SettingsDangerZone,
SettingsDangerButton,
} from './settings';

View file

@ -0,0 +1,107 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Card title (optional) */
title?: string;
/** Card description (optional) */
description?: string;
/** Visual variant */
variant?: 'default' | 'danger';
/** Additional CSS classes */
class?: string;
/** Content (SettingsRow components) */
children: Snippet;
}
let {
title,
description,
variant = 'default',
class: className = '',
children,
}: Props = $props();
</script>
<div class="settings-card settings-card--{variant} {className}">
{#if title || description}
<header class="settings-card__header">
{#if title}
<h3 class="settings-card__title">{title}</h3>
{/if}
{#if description}
<p class="settings-card__description">{description}</p>
{/if}
</header>
{/if}
<div class="settings-card__content">
{@render children()}
</div>
</div>
<style>
.settings-card {
/* Glass effect */
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-card {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.settings-card--danger {
border-color: hsl(var(--destructive) / 0.3);
background: rgba(239, 68, 68, 0.08);
}
:global(.dark) .settings-card--danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
}
.settings-card__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-card__header {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-card--danger .settings-card__header {
border-bottom-color: hsl(var(--destructive) / 0.2);
background: rgba(239, 68, 68, 0.1);
}
.settings-card__title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.settings-card--danger .settings-card__title {
color: hsl(var(--destructive));
}
.settings-card__description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.settings-card__content {
display: flex;
flex-direction: column;
}
</style>

View file

@ -0,0 +1,180 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Button label */
label: string;
/** Optional description */
description?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Click handler */
onclick: () => void;
/** Button text (default: label) */
buttonText?: string;
/** Show border at bottom */
border?: boolean;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
label,
description,
icon,
onclick,
buttonText,
border = true,
disabled = false,
class: className = '',
}: Props = $props();
</script>
<div
class="settings-danger-button {border ? 'settings-danger-button--border' : ''} {disabled ? 'settings-danger-button--disabled' : ''} {className}"
>
<div class="settings-danger-button__content">
{#if icon}
<span class="settings-danger-button__icon">
{@render icon()}
</span>
{/if}
<div class="settings-danger-button__text">
<span class="settings-danger-button__label">{label}</span>
{#if description}
<span class="settings-danger-button__description">{description}</span>
{/if}
</div>
</div>
<button
type="button"
{onclick}
class="settings-danger-button__button"
{disabled}
>
{buttonText || label}
</button>
</div>
<style>
.settings-danger-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-danger-button--border {
border-bottom: 1px solid rgba(239, 68, 68, 0.12);
}
:global(.dark) .settings-danger-button--border {
border-bottom-color: rgba(239, 68, 68, 0.18);
}
.settings-danger-button--border:last-child {
border-bottom: none;
}
.settings-danger-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-danger-button__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-danger-button__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(239, 68, 68, 0.1);
color: hsl(var(--destructive));
}
:global(.dark) .settings-danger-button__icon {
background: rgba(239, 68, 68, 0.15);
}
.settings-danger-button__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-danger-button__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-danger-button__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-danger-button__label {
color: #f3f4f6;
}
.settings-danger-button__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-danger-button__description {
color: #9ca3af;
}
.settings-danger-button__button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--destructive));
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0.5rem;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
:global(.dark) .settings-danger-button__button {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.25);
}
.settings-danger-button__button:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
}
:global(.dark) .settings-danger-button__button:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.35);
}
.settings-danger-button__button:disabled {
cursor: not-allowed;
}
.settings-danger-button__button:focus-visible {
outline: 2px solid rgba(239, 68, 68, 0.4);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Section title */
title?: string;
/** Additional CSS classes */
class?: string;
/** Content (danger actions) */
children: Snippet;
}
let {
title = 'Danger Zone',
class: className = '',
children,
}: Props = $props();
</script>
<section class="settings-danger-zone {className}">
<header class="settings-danger-zone__header">
<h2 class="settings-danger-zone__title">{title}</h2>
</header>
<div class="settings-danger-zone__content">
{@render children()}
</div>
</section>
<style>
.settings-danger-zone {
/* Glass effect with danger tint */
background: rgba(239, 68, 68, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(239, 68, 68, 0.1),
0 2px 4px -1px rgba(239, 68, 68, 0.06);
}
:global(.dark) .settings-danger-zone {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
}
.settings-danger-zone__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.15);
background: rgba(239, 68, 68, 0.1);
}
:global(.dark) .settings-danger-zone__header {
border-bottom-color: rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.15);
}
.settings-danger-zone__title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--destructive));
margin: 0;
}
.settings-danger-zone__content {
display: flex;
flex-direction: column;
}
</style>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Page title */
title: string;
/** Optional subtitle/description */
subtitle?: string;
/** Maximum width of the content */
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
/** Additional CSS classes */
class?: string;
/** Main content */
children: Snippet;
}
let {
title,
subtitle,
maxWidth = 'md',
class: className = '',
children,
}: Props = $props();
const maxWidthClasses = {
sm: 'max-w-lg',
md: 'max-w-2xl',
lg: 'max-w-3xl',
xl: 'max-w-4xl',
};
</script>
<div class="settings-page {className}">
<div class="settings-page__container {maxWidthClasses[maxWidth]}">
<header class="settings-page__header">
<h1 class="settings-page__title">{title}</h1>
{#if subtitle}
<p class="settings-page__subtitle">{subtitle}</p>
{/if}
</header>
<div class="settings-page__content">
{@render children()}
</div>
</div>
</div>
<style>
.settings-page {
min-height: calc(100vh - 4rem);
padding: 2rem 1rem;
background-color: hsl(var(--background));
}
.settings-page__container {
margin-left: auto;
margin-right: auto;
}
.settings-page__header {
margin-bottom: 2rem;
}
.settings-page__title {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.settings-page__subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.settings-page__content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 640px) {
.settings-page {
padding: 2rem 1.5rem;
}
.settings-page__title {
font-size: 1.75rem;
}
}
</style>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Row label */
label: string;
/** Optional description */
description?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Make the entire row clickable */
href?: string;
/** Click handler (alternative to href) */
onclick?: () => void;
/** Show border at bottom */
border?: boolean;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
/** Control element (Toggle, Button, etc.) */
children?: Snippet;
}
let {
label,
description,
icon,
href,
onclick,
border = true,
disabled = false,
class: className = '',
children,
}: Props = $props();
const isClickable = $derived(!!href || !!onclick);
</script>
{#if href}
<a
{href}
class="settings-row {border ? 'settings-row--border' : ''} settings-row--clickable {disabled ? 'settings-row--disabled' : ''} {className}"
>
<div class="settings-row__content">
{#if icon}
<span class="settings-row__icon">
{@render icon()}
</span>
{/if}
<div class="settings-row__text">
<span class="settings-row__label">{label}</span>
{#if description}
<span class="settings-row__description">{description}</span>
{/if}
</div>
</div>
<div class="settings-row__control">
{#if children}
{@render children()}
{:else}
<svg class="settings-row__chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{/if}
</div>
</a>
{:else if onclick}
<button
type="button"
{onclick}
class="settings-row {border ? 'settings-row--border' : ''} settings-row--clickable {disabled ? 'settings-row--disabled' : ''} {className}"
{disabled}
>
<div class="settings-row__content">
{#if icon}
<span class="settings-row__icon">
{@render icon()}
</span>
{/if}
<div class="settings-row__text">
<span class="settings-row__label">{label}</span>
{#if description}
<span class="settings-row__description">{description}</span>
{/if}
</div>
</div>
<div class="settings-row__control">
{#if children}
{@render children()}
{:else}
<svg class="settings-row__chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{/if}
</div>
</button>
{:else}
<div
class="settings-row {border ? 'settings-row--border' : ''} {disabled ? 'settings-row--disabled' : ''} {className}"
>
<div class="settings-row__content">
{#if icon}
<span class="settings-row__icon">
{@render icon()}
</span>
{/if}
<div class="settings-row__text">
<span class="settings-row__label">{label}</span>
{#if description}
<span class="settings-row__description">{description}</span>
{/if}
</div>
</div>
{#if children}
<div class="settings-row__control">
{@render children()}
</div>
{/if}
</div>
{/if}
<style>
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: transparent;
border: none;
width: 100%;
text-align: left;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.settings-row--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-row--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-row--border:last-child {
border-bottom: none;
}
.settings-row--clickable {
cursor: pointer;
}
.settings-row--clickable:hover {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .settings-row--clickable:hover {
background: rgba(255, 255, 255, 0.06);
}
.settings-row--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.settings-row__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-row__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-row__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-row__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-row__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-row__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-row__label {
color: #f3f4f6;
}
.settings-row__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-row__description {
color: #9ca3af;
}
.settings-row__control {
display: flex;
align-items: center;
flex-shrink: 0;
}
.settings-row__chevron {
width: 1.25rem;
height: 1.25rem;
color: #9ca3af;
}
:global(.dark) .settings-row__chevron {
color: #6b7280;
}
</style>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Section title */
title?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Additional CSS classes */
class?: string;
/** Content (SettingsCard components) */
children: Snippet;
}
let {
title,
icon,
class: className = '',
children,
}: Props = $props();
</script>
<section class="settings-section {className}">
{#if title}
<header class="settings-section__header">
{#if icon}
<span class="settings-section__icon">
{@render icon()}
</span>
{/if}
<h2 class="settings-section__title">{title}</h2>
</header>
{/if}
<div class="settings-section__content">
{@render children()}
</div>
</section>
<style>
.settings-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.settings-section__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0.25rem;
}
.settings-section__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-section__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-section__icon :global(svg) {
width: 1rem;
height: 1rem;
}
.settings-section__title {
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
margin: 0;
letter-spacing: -0.01em;
}
:global(.dark) .settings-section__title {
color: #f3f4f6;
}
.settings-section__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,202 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Row label */
label: string;
/** Optional description */
description?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Toggle state */
isOn: boolean;
/** Toggle handler */
onToggle: (value: boolean) => void;
/** Show border at bottom */
border?: boolean;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
label,
description,
icon,
isOn = false,
onToggle,
border = true,
disabled = false,
class: className = '',
}: Props = $props();
function handleToggle() {
if (!disabled) {
onToggle(!isOn);
}
}
</script>
<div
class="settings-toggle {border ? 'settings-toggle--border' : ''} {disabled ? 'settings-toggle--disabled' : ''} {className}"
>
<div class="settings-toggle__content">
{#if icon}
<span class="settings-toggle__icon">
{@render icon()}
</span>
{/if}
<div class="settings-toggle__text">
<span class="settings-toggle__label">{label}</span>
{#if description}
<span class="settings-toggle__description">{description}</span>
{/if}
</div>
</div>
<button
type="button"
onclick={handleToggle}
class="settings-toggle__switch {isOn ? 'settings-toggle__switch--on' : ''}"
role="switch"
aria-checked={isOn}
aria-label={label}
{disabled}
>
<span class="settings-toggle__thumb"></span>
</button>
</div>
<style>
.settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-toggle--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-toggle--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-toggle--border:last-child {
border-bottom: none;
}
.settings-toggle--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-toggle__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-toggle__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-toggle__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-toggle__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-toggle__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-toggle__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-toggle__label {
color: #f3f4f6;
}
.settings-toggle__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-toggle__description {
color: #9ca3af;
}
/* Toggle Switch - Glass style */
.settings-toggle__switch {
position: relative;
width: 3rem;
height: 1.75rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.08);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
:global(.dark) .settings-toggle__switch {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
}
.settings-toggle__switch:disabled {
cursor: not-allowed;
}
.settings-toggle__switch--on {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}
.settings-toggle__thumb {
position: absolute;
top: 0.0625rem;
left: 0.0625rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 9999px;
background-color: white;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.15),
0 1px 2px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.settings-toggle__switch--on .settings-toggle__thumb {
transform: translateX(1.25rem);
}
.settings-toggle__switch:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,8 @@
// Settings Components
export { default as SettingsPage } from './SettingsPage.svelte';
export { default as SettingsSection } from './SettingsSection.svelte';
export { default as SettingsCard } from './SettingsCard.svelte';
export { default as SettingsRow } from './SettingsRow.svelte';
export { default as SettingsToggle } from './SettingsToggle.svelte';
export { default as SettingsDangerZone } from './SettingsDangerZone.svelte';
export { default as SettingsDangerButton } from './SettingsDangerButton.svelte';