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:
35
src/lib/components/ui/FormField.svelte
Normal file
35
src/lib/components/ui/FormField.svelte
Normal 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>
|
||||
54
src/lib/components/ui/ThemedCard.svelte
Normal file
54
src/lib/components/ui/ThemedCard.svelte
Normal 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>
|
||||
6
src/lib/components/ui/select/index.ts
Normal file
6
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from './select.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Root as Select
|
||||
};
|
||||
22
src/lib/components/ui/select/select.svelte
Normal file
22
src/lib/components/ui/select/select.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
41
src/lib/utils/currency.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user