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 { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
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 ImageSelector from './ImageSelector.svelte';
|
||||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { Item } from '$lib/db/schema';
|
import type { Item } from '$lib/db/schema';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
import { CURRENCIES } from '$lib/utils/currency';
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
@@ -37,9 +37,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isEdit = mode === 'edit' && item !== null;
|
const isEdit = mode === 'edit' && item !== null;
|
||||||
const cardStyle = $derived(getCardStyle(wishlistColor, null));
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
|
|
||||||
|
|
||||||
// Form state - initialized from item if editing
|
// Form state - initialized from item if editing
|
||||||
let linkUrl = $state(item?.link || '');
|
let linkUrl = $state(item?.link || '');
|
||||||
@@ -114,12 +112,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
<ThemedCard theme={wishlistTheme} color={wishlistColor} title={titleLabel}>
|
||||||
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
|
|
||||||
<CardHeader class="relative z-10">
|
|
||||||
<CardTitle>{titleLabel}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="relative z-10">
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={formAction}
|
action={formAction}
|
||||||
@@ -201,17 +194,11 @@
|
|||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
<div class="space-y-2 md:col-span-2">
|
||||||
<Label for="currency">{t.form.currency}</Label>
|
<Label for="currency">{t.form.currency}</Label>
|
||||||
<select
|
<Select id="currency" name="currency" value={item?.currency || 'DKK'}>
|
||||||
id="currency"
|
{#each CURRENCIES as curr (curr)}
|
||||||
name="currency"
|
<option value={curr}>{curr}</option>
|
||||||
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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
@@ -251,5 +238,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</ThemedCard>
|
||||||
</Card>
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<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 type { Item } from '$lib/db/schema';
|
||||||
import { GripVertical, ExternalLink } from '@lucide/svelte';
|
import { GripVertical, ExternalLink } from '@lucide/svelte';
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
import { formatPrice } from '$lib/utils/currency';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -26,36 +25,9 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
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>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
<ThemedCard color={item.color} fallbackColor={wishlistColor} theme={theme} padding="none">
|
||||||
<ThemeCard themeName={theme} color={item.color} showPattern={false} />
|
|
||||||
<CardContent class="p-6 relative z-10">
|
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
{#if showDragHandle}
|
{#if showDragHandle}
|
||||||
<div
|
<div
|
||||||
@@ -121,5 +93,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</ThemedCard>
|
||||||
</Card>
|
|
||||||
|
|||||||
@@ -93,8 +93,9 @@ export function sanitizeToken(token: string | null | undefined): string {
|
|||||||
|
|
||||||
// Zod schemas for type-safe form validation
|
// Zod schemas for type-safe form validation
|
||||||
import { z } from 'zod';
|
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({
|
export const itemSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required').max(255),
|
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