feat: add UI component library (toast, search, badges, modals, upload, table)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { headers, children }: {
|
||||||
|
headers: string[];
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto bg-white rounded-xl shadow">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b bg-gray-50">
|
||||||
|
{#each headers as header}
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{header}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{@render children()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { open = $bindable(false), title = 'Confirm', message = 'Are you sure?', onconfirm, oncancel }: {
|
||||||
|
open?: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
onconfirm?: () => void;
|
||||||
|
oncancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="fixed inset-0 bg-black/50" onclick={() => { open = false; oncancel?.(); }} role="button" tabindex="-1" onkeydown={() => {}}></div>
|
||||||
|
<div class="relative bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-lg font-bold mb-2">{title}</h3>
|
||||||
|
<p class="text-gray-600 mb-6">{message}</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => { open = false; oncancel?.(); }}
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>Cancel</button>
|
||||||
|
<button
|
||||||
|
onclick={() => { open = false; onconfirm?.(); }}
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
>Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { accept = 'image/*', multiple = false, onfiles }: {
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
onfiles?: (files: FileList) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let input: HTMLInputElement;
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
if (e.dataTransfer?.files.length) {
|
||||||
|
onfiles?.(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target.files?.length) {
|
||||||
|
onfiles?.(target.files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
|
||||||
|
{dragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary'}"
|
||||||
|
ondragover={(e) => { e.preventDefault(); dragging = true; }}
|
||||||
|
ondragleave={() => dragging = false}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
onclick={() => input.click()}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') input.click(); }}
|
||||||
|
>
|
||||||
|
<input bind:this={input} type="file" {accept} {multiple} class="hidden" onchange={handleChange} />
|
||||||
|
<p class="text-gray-500">Drop files here or click to browse</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { page = 1, totalPages = 1, onpage }: {
|
||||||
|
page?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
onpage?: (page: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<nav class="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => onpage?.(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||||
|
>←</button>
|
||||||
|
|
||||||
|
<span class="px-3 py-1 text-sm text-gray-600">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => onpage?.(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||||
|
>→</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { value = $bindable<number | null>(null), placeholder = 'Search for a pet...' }: {
|
||||||
|
value?: number | null;
|
||||||
|
placeholder?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let results = $state<Array<{ id: number; name: string }>>([]);
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (query.length < 2) { results = []; return; }
|
||||||
|
const res = await fetch(`/api/pets?q=${encodeURIComponent(query)}&limit=10`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
results = data.pets || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(pet: { id: number; name: string }) {
|
||||||
|
value = pet.id;
|
||||||
|
query = pet.name;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={query}
|
||||||
|
{placeholder}
|
||||||
|
oninput={search}
|
||||||
|
onfocus={() => open = true}
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
{#if open && results.length > 0}
|
||||||
|
<ul class="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{#each results as pet}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="w-full px-4 py-2 text-left hover:bg-gray-50"
|
||||||
|
onclick={() => select(pet)}
|
||||||
|
>{pet.name}</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { value = $bindable(''), placeholder = 'Search...', onsubmit }: {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onsubmit?: (value: string) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="relative"
|
||||||
|
onsubmit={(e) => { e.preventDefault(); onsubmit?.(value); }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
class="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { status }: { status: string } = $props();
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
available: 'bg-green-100 text-green-800',
|
||||||
|
adopted: 'bg-blue-100 text-blue-800',
|
||||||
|
foster: 'bg-purple-100 text-purple-800',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
medical_hold: 'bg-red-100 text-red-800',
|
||||||
|
behavioral_hold: 'bg-orange-100 text-orange-800',
|
||||||
|
trial: 'bg-teal-100 text-teal-800',
|
||||||
|
hospice: 'bg-pink-100 text-pink-800',
|
||||||
|
deceased: 'bg-gray-100 text-gray-800',
|
||||||
|
transferred: 'bg-gray-100 text-gray-800',
|
||||||
|
approved: 'bg-green-100 text-green-800',
|
||||||
|
denied: 'bg-red-100 text-red-800',
|
||||||
|
reviewing: 'bg-yellow-100 text-yellow-800',
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
suspended: 'bg-red-100 text-red-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = $derived(colorMap[status] || 'bg-gray-100 text-gray-800');
|
||||||
|
const label = $derived(status.replace(/_/g, ' '));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize {colors}">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getToasts, removeToast } from '$lib/toast.svelte';
|
||||||
|
|
||||||
|
const toasts = $derived(getToasts());
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
success: 'bg-green-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
info: 'bg-primary'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if toasts.length > 0}
|
||||||
|
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{#each toasts as toast (toast.id)}
|
||||||
|
<div class="{colors[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-sm">
|
||||||
|
<span class="flex-1">{toast.message}</span>
|
||||||
|
<button onclick={() => removeToast(toast.id)} class="text-white/80 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user