refactor: create shared UI components and utilities

- Add Select UI component
- Create currency utilities (CURRENCIES, formatPrice)
- Create ThemedCard wrapper component
- Create FormField wrapper component
- Update ItemForm and WishlistItem to use new abstractions
- Standardize currency type with single source of truth
This commit is contained in:
Rasmus Q
2026-03-16 14:51:33 +00:00
parent 6d3a418525
commit 07c098df77
8 changed files with 173 additions and 57 deletions

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import type { Snippet } from 'svelte';
interface Props {
id: string;
label: string;
required?: boolean;
children: Snippet;
class?: string;
description?: string;
}
let {
id,
label,
required = false,
children,
class: className = '',
description
}: Props = $props();
</script>
<div class="space-y-2 {className}">
<Label for={id}>
{label}
{#if required}
<span class="text-muted-foreground">(required)</span>
{/if}
</Label>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
{@render children()}
</div>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { getCardStyle } from '$lib/utils/colors';
interface Props {
color?: string | null;
fallbackColor?: string | null;
theme?: string | null;
showPattern?: boolean;
title?: string;
description?: string;
children?: import('svelte').Snippet;
class?: string;
padding?: 'none' | 'normal' | 'large';
}
let {
color = null,
fallbackColor = null,
theme = null,
showPattern = false,
title,
description,
children,
class: className = '',
padding = 'normal'
}: Props = $props();
const cardStyle = $derived(getCardStyle(color, fallbackColor));
const paddingClasses = {
none: 'p-0',
normal: 'p-6',
large: 'p-12'
};
</script>
<Card style={cardStyle} class="relative overflow-hidden {className}">
<ThemeCard themeName={theme} {color} {showPattern} />
{#if title}
<CardHeader class="relative z-10">
<CardTitle>{title}</CardTitle>
{#if description}
<CardDescription>{description}</CardDescription>
{/if}
</CardHeader>
{/if}
<CardContent class="{paddingClasses[padding]} relative z-10">
{@render children?.()}
</CardContent>
</Card>

View File

@@ -0,0 +1,6 @@
import Root from './select.svelte';
export {
Root,
Root as Select
};

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLSelectAttributes } from 'svelte/elements';
type Props = HTMLSelectAttributes & {
value?: string;
children?: import('svelte').Snippet;
};
let { class: className, value = $bindable(''), children, ...restProps }: Props = $props();
</script>
<select
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...restProps}
>
{@render children?.()}
</select>

View File

@@ -3,14 +3,14 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Select } from '$lib/components/ui/select';
import ThemedCard from '$lib/components/ui/ThemedCard.svelte';
import ImageSelector from './ImageSelector.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { enhance } from '$app/forms';
import type { Item } from '$lib/db/schema';
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { getCardStyle } from '$lib/utils/colors';
import { CURRENCIES } from '$lib/utils/currency';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
@@ -37,9 +37,7 @@
}: Props = $props();
const isEdit = mode === 'edit' && item !== null;
const cardStyle = $derived(getCardStyle(wishlistColor, null));
const t = $derived(languageStore.t);
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
// Form state - initialized from item if editing
let linkUrl = $state(item?.link || '');
@@ -114,12 +112,7 @@
});
</script>
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
<CardHeader class="relative z-10">
<CardTitle>{titleLabel}</CardTitle>
</CardHeader>
<CardContent class="relative z-10">
<ThemedCard theme={wishlistTheme} color={wishlistColor} title={titleLabel}>
<form
method="POST"
action={formAction}
@@ -201,17 +194,11 @@
<div class="space-y-2 md:col-span-2">
<Label for="currency">{t.form.currency}</Label>
<select
id="currency"
name="currency"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
>
{#each currencies as curr (curr)}
<option value={curr} selected={curr === (item?.currency || 'DKK')}>
{curr}
</option>
<Select id="currency" name="currency" value={item?.currency || 'DKK'}>
{#each CURRENCIES as curr (curr)}
<option value={curr}>{curr}</option>
{/each}
</select>
</Select>
</div>
<div class="md:col-span-2">
@@ -251,5 +238,4 @@
{/if}
</div>
</form>
</CardContent>
</Card>
</ThemedCard>

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import { Card, CardContent } from '$lib/components/ui/card';
import ThemedCard from '$lib/components/ui/ThemedCard.svelte';
import type { Item } from '$lib/db/schema';
import { GripVertical, ExternalLink } from '@lucide/svelte';
import { getCardStyle } from '$lib/utils/colors';
import { Button } from '$lib/components/ui/button';
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { formatPrice } from '$lib/utils/currency';
interface Props {
item: Item;
@@ -26,36 +25,9 @@
}: Props = $props();
const t = $derived(languageStore.t);
const currencySymbols: Record<string, string> = {
DKK: 'kr',
EUR: '€',
USD: '$',
SEK: 'kr',
NOK: 'kr',
GBP: '£'
};
function formatPrice(price: string | null, currency: string | null): string {
if (!price) return '';
const symbol = currency ? currencySymbols[currency] || currency : 'kr';
const amount = parseFloat(price).toFixed(2);
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && ['DKK', 'SEK', 'NOK'].includes(currency)) {
return `${amount} ${symbol}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}
const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
</script>
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={item.color} showPattern={false} />
<CardContent class="p-6 relative z-10">
<ThemedCard color={item.color} fallbackColor={wishlistColor} theme={theme} padding="none">
<div class="flex gap-4">
{#if showDragHandle}
<div
@@ -121,5 +93,4 @@
</div>
</div>
</div>
</CardContent>
</Card>
</ThemedCard>

View File

@@ -93,8 +93,9 @@ export function sanitizeToken(token: string | null | undefined): string {
// Zod schemas for type-safe form validation
import { z } from 'zod';
import { CURRENCIES } from '$lib/utils/currency';
export const currencySchema = z.enum(['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP']);
export const currencySchema = z.enum(CURRENCIES);
export const itemSchema = z.object({
title: z.string().min(1, 'Title is required').max(255),

41
src/lib/utils/currency.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Currency utilities for formatting and displaying prices
*/
export const CURRENCIES = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'] as const;
export type Currency = (typeof CURRENCIES)[number];
export const currencySymbols: Record<Currency, string> = {
DKK: 'kr',
EUR: '€',
USD: '$',
SEK: 'kr',
NOK: 'kr',
GBP: '£'
};
/**
* Check if a currency symbol should be placed after the amount
*/
export function isPostfixCurrency(currency: Currency): boolean {
return ['DKK', 'SEK', 'NOK'].includes(currency);
}
/**
* Format a price with the appropriate currency symbol
*/
export function formatPrice(price: string | number | null, currency: Currency | null): string {
if (!price) return '';
const symbol = currency ? currencySymbols[currency] || currency : 'kr';
const amount = typeof price === 'string' ? parseFloat(price).toFixed(2) : price.toFixed(2);
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && isPostfixCurrency(currency)) {
return `${amount} ${symbol}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}