First commit
This commit is contained in:
124
src/lib/components/calendar/Calendar.svelte
Normal file
124
src/lib/components/calendar/Calendar.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import type { CalendarEvent } from '$lib/supabase/types';
|
||||
import { getMonthDays, isSameDay } from '$lib/api/calendar';
|
||||
|
||||
interface Props {
|
||||
events: CalendarEvent[];
|
||||
onDateClick?: (date: Date) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { events, onDateClick, onEventClick }: Props = $props();
|
||||
|
||||
let currentDate = $state(new Date());
|
||||
const today = new Date();
|
||||
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const days = $derived(getMonthDays(currentDate.getFullYear(), currentDate.getMonth()));
|
||||
|
||||
function prevMonth() {
|
||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
currentDate = new Date();
|
||||
}
|
||||
|
||||
function getEventsForDay(date: Date): CalendarEvent[] {
|
||||
return events.filter((event) => {
|
||||
const eventStart = new Date(event.start_time);
|
||||
return isSameDay(eventStart, date);
|
||||
});
|
||||
}
|
||||
|
||||
function isCurrentMonth(date: Date): boolean {
|
||||
return date.getMonth() === currentDate.getMonth();
|
||||
}
|
||||
|
||||
const monthYear = $derived(
|
||||
currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="bg-surface rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-light">{monthYear}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={goToToday}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={prevMonth}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={nextMonth}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden">
|
||||
{#each weekDays as day}
|
||||
<div class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50">
|
||||
{day}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each days as day}
|
||||
{@const dayEvents = getEventsForDay(day)}
|
||||
{@const isToday = isSameDay(day, today)}
|
||||
{@const inMonth = isCurrentMonth(day)}
|
||||
<button
|
||||
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5"
|
||||
class:opacity-40={!inMonth}
|
||||
onclick={() => onDateClick?.(day)}
|
||||
>
|
||||
<div class="flex items-center justify-center w-7 h-7 mb-1">
|
||||
<span
|
||||
class="text-sm {isToday
|
||||
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center'
|
||||
: 'text-light/80'}"
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each dayEvents.slice(0, 3) as event}
|
||||
<button
|
||||
class="w-full text-xs px-1 py-0.5 rounded truncate text-left"
|
||||
style="background-color: {event.color ?? '#6366f1'}20; color: {event.color ?? '#6366f1'}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick?.(event);
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
{#if dayEvents.length > 3}
|
||||
<p class="text-xs text-light/40 px-1">+{dayEvents.length - 3} more</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
1
src/lib/components/calendar/index.ts
Normal file
1
src/lib/components/calendar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Calendar } from './Calendar.svelte';
|
||||
201
src/lib/components/documents/Editor.svelte
Normal file
201
src/lib/components/documents/Editor.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
|
||||
interface Props {
|
||||
content?: object | null;
|
||||
editable?: boolean;
|
||||
placeholder?: string;
|
||||
onUpdate?: (content: object) => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
content = null,
|
||||
editable = true,
|
||||
placeholder = 'Start writing...',
|
||||
onUpdate,
|
||||
onSave
|
||||
}: Props = $props();
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let editor: Editor | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder })
|
||||
],
|
||||
content: content ?? undefined,
|
||||
editable,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate?.(editor.getJSON());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4'
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
onSave?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
export function setContent(newContent: object | null) {
|
||||
if (editor && newContent) {
|
||||
editor.commands.setContent(newContent);
|
||||
}
|
||||
}
|
||||
|
||||
export function getContent() {
|
||||
return editor?.getJSON() ?? null;
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
editor?.commands.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
|
||||
{#if editable}
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50">
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class:text-primary={editor?.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class:text-primary={editor?.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="19" y1="4" x2="10" y2="4" />
|
||||
<line x1="14" y1="20" x2="5" y2="20" />
|
||||
<line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class:text-primary={editor?.isActive('strike')}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4H9a3 3 0 0 0-2.83 4" />
|
||||
<path d="M14 12a4 4 0 0 1 0 8H6" />
|
||||
<line x1="4" y1="12" x2="20" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-light/20 mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
class:text-primary={editor?.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<span class="text-xs font-bold">H1</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
class:text-primary={editor?.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<span class="text-xs font-bold">H2</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
class:text-primary={editor?.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<span class="text-xs font-bold">H3</span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-light/20 mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
class:text-primary={editor?.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="4" cy="6" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="18" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
class:text-primary={editor?.isActive('orderedList')}
|
||||
title="Numbered List"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="10" y1="6" x2="21" y2="6" />
|
||||
<line x1="10" y1="12" x2="21" y2="12" />
|
||||
<line x1="10" y1="18" x2="21" y2="18" />
|
||||
<text x="3" y="8" font-size="8" fill="currentColor">1</text>
|
||||
<text x="3" y="14" font-size="8" fill="currentColor">2</text>
|
||||
<text x="3" y="20" font-size="8" fill="currentColor">3</text>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class:text-primary={editor?.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class:text-primary={editor?.isActive('codeBlock')}
|
||||
title="Code Block"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16,18 22,12 16,6" />
|
||||
<polyline points="8,6 2,12 8,18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div bind:this={element}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-light) / 0.3);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
192
src/lib/components/documents/FileTree.svelte
Normal file
192
src/lib/components/documents/FileTree.svelte
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import type { DocumentWithChildren } from "$lib/api/documents";
|
||||
|
||||
interface Props {
|
||||
items: DocumentWithChildren[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (doc: DocumentWithChildren) => void;
|
||||
onAdd?: (parentId: string | null) => void;
|
||||
onMove?: (docId: string, newParentId: string | null) => void;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedId = null,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onMove,
|
||||
level = 0,
|
||||
}: Props = $props();
|
||||
|
||||
let expandedFolders = $state<Set<string>>(new Set());
|
||||
let dragOverId = $state<string | null>(null);
|
||||
|
||||
function toggleFolder(id: string, e?: MouseEvent) {
|
||||
e?.stopPropagation();
|
||||
const newSet = new Set(expandedFolders);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
expandedFolders = newSet;
|
||||
}
|
||||
|
||||
function handleSelect(doc: DocumentWithChildren) {
|
||||
onSelect(doc);
|
||||
}
|
||||
|
||||
function handleAdd(e: MouseEvent, parentId: string | null) {
|
||||
e.stopPropagation();
|
||||
onAdd?.(parentId);
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, doc: DocumentWithChildren) {
|
||||
if (!e.dataTransfer) return;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", doc.id);
|
||||
}
|
||||
|
||||
function handleDragOver(
|
||||
e: DragEvent,
|
||||
targetId: string | null,
|
||||
isFolder: boolean,
|
||||
) {
|
||||
if (!isFolder && targetId !== null) return;
|
||||
e.preventDefault();
|
||||
dragOverId = targetId;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverId = null;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, targetFolderId: string | null) {
|
||||
e.preventDefault();
|
||||
dragOverId = null;
|
||||
const docId = e.dataTransfer?.getData("text/plain");
|
||||
if (docId && docId !== targetFolderId) {
|
||||
onMove?.(docId, targetFolderId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="space-y-0.5"
|
||||
ondragover={(e) => level === 0 && handleDragOver(e, null, true)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => level === 0 && handleDrop(e, null)}
|
||||
role="tree"
|
||||
>
|
||||
{#each items as item}
|
||||
<div role="treeitem">
|
||||
<div
|
||||
class="group w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors cursor-pointer
|
||||
{selectedId === item.id
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-light/80 hover:bg-light/5'}
|
||||
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
||||
onclick={() => handleSelect(item)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, item)}
|
||||
ondragover={(e) =>
|
||||
handleDragOver(e, item.id, item.type === "folder")}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => item.type === "folder" && handleDrop(e, item.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if item.type === "folder"}
|
||||
<button
|
||||
class="p-0.5 hover:bg-light/10 rounded"
|
||||
onclick={(e) => toggleFolder(item.id, e)}
|
||||
aria-label="Toggle folder"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {expandedFolders.has(
|
||||
item.id,
|
||||
)
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
class="w-4 h-4 text-warning"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H12L10 5H5C3.89543 5 3 5.89543 3 7Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="w-5"></div>
|
||||
<svg
|
||||
class="w-4 h-4 text-light/50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="flex-1 truncate text-sm">{item.name}</span>
|
||||
|
||||
{#if item.type === "folder" && onAdd}
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-light/10 rounded transition-opacity"
|
||||
onclick={(e) => handleAdd(e, item.id)}
|
||||
aria-label="Add to folder"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if item.type === "folder" && expandedFolders.has(item.id)}
|
||||
<div class="ml-4 border-l border-light/10 pl-2">
|
||||
{#if item.children?.length}
|
||||
<svelte:self
|
||||
items={item.children}
|
||||
{selectedId}
|
||||
{onSelect}
|
||||
{onAdd}
|
||||
{onMove}
|
||||
level={level + 1}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-light/30 text-xs px-3 py-2 italic">
|
||||
Empty folder
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if items.length === 0 && level === 0}
|
||||
<p class="text-light/40 text-sm px-3 py-2">No documents yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
2
src/lib/components/documents/index.ts
Normal file
2
src/lib/components/documents/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FileTree } from './FileTree.svelte';
|
||||
export { default as Editor } from './Editor.svelte';
|
||||
231
src/lib/components/kanban/CardDetailModal.svelte
Normal file
231
src/lib/components/kanban/CardDetailModal.svelte
Normal file
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { Modal, Button, Input, Textarea } from '$lib/components/ui';
|
||||
import type { KanbanCard } from '$lib/supabase/types';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database } from '$lib/supabase/types';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
card_id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: KanbanCard | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpdate: (card: KanbanCard) => void;
|
||||
onDelete: (cardId: string) => void;
|
||||
}
|
||||
|
||||
let { card, isOpen, onClose, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>('supabase');
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let checklist = $state<ChecklistItem[]>([]);
|
||||
let newItemTitle = $state('');
|
||||
let isLoading = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (card && isOpen) {
|
||||
title = card.title;
|
||||
description = card.description ?? '';
|
||||
loadChecklist();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadChecklist() {
|
||||
if (!card) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('checklist_items')
|
||||
.select('*')
|
||||
.eq('card_id', card.id)
|
||||
.order('position');
|
||||
|
||||
checklist = (data ?? []) as ChecklistItem[];
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!card) return;
|
||||
isSaving = true;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('kanban_cards')
|
||||
.update({
|
||||
title,
|
||||
description: description || null
|
||||
})
|
||||
.eq('id', card.id);
|
||||
|
||||
if (!error) {
|
||||
onUpdate({ ...card, title, description: description || null });
|
||||
}
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function handleAddItem() {
|
||||
if (!card || !newItemTitle.trim()) return;
|
||||
|
||||
const position = checklist.length;
|
||||
const { data, error } = await supabase
|
||||
.from('checklist_items')
|
||||
.insert({
|
||||
card_id: card.id,
|
||||
title: newItemTitle,
|
||||
position,
|
||||
completed: false
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
checklist = [...checklist, data as ChecklistItem];
|
||||
newItemTitle = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleItem(item: ChecklistItem) {
|
||||
const { error } = await supabase
|
||||
.from('checklist_items')
|
||||
.update({ completed: !item.completed })
|
||||
.eq('id', item.id);
|
||||
|
||||
if (!error) {
|
||||
checklist = checklist.map(i =>
|
||||
i.id === item.id ? { ...i, completed: !i.completed } : i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(itemId: string) {
|
||||
const { error } = await supabase
|
||||
.from('checklist_items')
|
||||
.delete()
|
||||
.eq('id', itemId);
|
||||
|
||||
if (!error) {
|
||||
checklist = checklist.filter(i => i.id !== itemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!card || !confirm('Delete this card?')) return;
|
||||
|
||||
await supabase
|
||||
.from('kanban_cards')
|
||||
.delete()
|
||||
.eq('id', card.id);
|
||||
|
||||
onDelete(card.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const completedCount = $derived(checklist.filter(i => i.completed).length);
|
||||
const progress = $derived(checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0);
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} {onClose} title="Card Details" size="lg">
|
||||
{#if card}
|
||||
<div class="space-y-5">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
placeholder="Card title"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
bind:value={description}
|
||||
placeholder="Add a more detailed description..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="text-sm font-medium text-light">Checklist</label>
|
||||
{#if checklist.length > 0}
|
||||
<span class="text-xs text-light/50">{completedCount}/{checklist.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if checklist.length > 0}
|
||||
<div class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-success transition-all duration-300"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="text-light/50 text-sm py-2">Loading...</div>
|
||||
{:else}
|
||||
<div class="space-y-2 mb-3">
|
||||
{#each checklist as item}
|
||||
<div class="flex items-center gap-3 group">
|
||||
<button
|
||||
class="w-5 h-5 rounded border flex items-center justify-center transition-colors
|
||||
{item.completed ? 'bg-success border-success' : 'border-light/30 hover:border-light/50'}"
|
||||
onclick={() => toggleItem(item)}
|
||||
>
|
||||
{#if item.completed}
|
||||
<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="flex-1 text-sm {item.completed ? 'line-through text-light/40' : 'text-light'}">
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
|
||||
onclick={() => deleteItem(item.id)}
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
placeholder="Add an item..."
|
||||
bind:value={newItemTitle}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAddItem()}
|
||||
/>
|
||||
<Button size="sm" onclick={handleAddItem} disabled={!newItemTitle.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-light/10">
|
||||
<Button variant="danger" onclick={handleDelete}>
|
||||
Delete Card
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
||||
<Button onclick={handleSave} loading={isSaving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
162
src/lib/components/kanban/KanbanBoard.svelte
Normal file
162
src/lib/components/kanban/KanbanBoard.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { ColumnWithCards } from '$lib/api/kanban';
|
||||
import type { KanbanCard } from '$lib/supabase/types';
|
||||
import { Button, Card, Badge } from '$lib/components/ui';
|
||||
|
||||
interface Props {
|
||||
columns: ColumnWithCards[];
|
||||
onCardClick?: (card: KanbanCard) => void;
|
||||
onCardMove?: (cardId: string, toColumnId: string, toPosition: number) => void;
|
||||
onAddCard?: (columnId: string) => void;
|
||||
onAddColumn?: () => void;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
onCardClick,
|
||||
onCardMove,
|
||||
onAddCard,
|
||||
onAddColumn,
|
||||
canEdit = true
|
||||
}: Props = $props();
|
||||
|
||||
let draggedCard = $state<KanbanCard | null>(null);
|
||||
let dragOverColumn = $state<string | null>(null);
|
||||
|
||||
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
||||
draggedCard = card;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', card.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, columnId: string) {
|
||||
e.preventDefault();
|
||||
dragOverColumn = columnId;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverColumn = null;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, columnId: string) {
|
||||
e.preventDefault();
|
||||
dragOverColumn = null;
|
||||
|
||||
if (draggedCard && draggedCard.column_id !== columnId) {
|
||||
const column = columns.find((c) => c.id === columnId);
|
||||
const newPosition = column?.cards.length ?? 0;
|
||||
onCardMove?.(draggedCard.id, columnId, newPosition);
|
||||
}
|
||||
draggedCard = null;
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return 'Overdue';
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function getDueDateColor(dateStr: string | null): 'error' | 'warning' | 'default' {
|
||||
if (!dateStr) return 'default';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return 'error';
|
||||
if (days <= 2) return 'warning';
|
||||
return 'default';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px]">
|
||||
{#each columns as column}
|
||||
<div
|
||||
class="flex-shrink-0 w-72 bg-surface rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)]"
|
||||
class:ring-2={dragOverColumn === column.id}
|
||||
class:ring-primary={dragOverColumn === column.id}
|
||||
ondragover={(e) => handleDragOver(e, column.id)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, column.id)}
|
||||
role="list"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3 px-1">
|
||||
<h3 class="font-medium text-light flex items-center gap-2">
|
||||
{column.name}
|
||||
<span class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded">
|
||||
{column.cards.length}
|
||||
</span>
|
||||
</h3>
|
||||
{#if column.color}
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: {column.color}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
{#each column.cards as card}
|
||||
<div
|
||||
class="bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all"
|
||||
class:opacity-50={draggedCard?.id === card.id}
|
||||
draggable={canEdit}
|
||||
ondragstart={(e) => handleDragStart(e, card)}
|
||||
onclick={() => onCardClick?.(card)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onCardClick?.(card)}
|
||||
role="listitem"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if card.color}
|
||||
<div class="w-full h-1 rounded-full mb-2" style="background-color: {card.color}"></div>
|
||||
{/if}
|
||||
<p class="text-sm text-light">{card.title}</p>
|
||||
{#if card.description}
|
||||
<p class="text-xs text-light/50 mt-1 line-clamp-2">{card.description}</p>
|
||||
{/if}
|
||||
{#if card.due_date}
|
||||
<div class="mt-2">
|
||||
<Badge size="sm" variant={getDueDateColor(card.due_date)}>
|
||||
{formatDueDate(card.due_date)}
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1"
|
||||
onclick={() => onAddCard?.(column.id)}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add card
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors"
|
||||
onclick={() => onAddColumn?.()}
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add column
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
2
src/lib/components/kanban/index.ts
Normal file
2
src/lib/components/kanban/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
||||
92
src/lib/components/ui/Avatar.svelte
Normal file
92
src/lib/components/ui/Avatar.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src?: string | null;
|
||||
name?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
status?: 'online' | 'offline' | 'away' | 'busy' | null;
|
||||
}
|
||||
|
||||
let { src = null, name = '?', size = 'md', status = null }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-xs',
|
||||
sm: 'w-8 h-8 text-sm',
|
||||
md: 'w-10 h-10 text-base',
|
||||
lg: 'w-12 h-12 text-lg',
|
||||
xl: 'w-16 h-16 text-xl',
|
||||
'2xl': 'w-20 h-20 text-2xl'
|
||||
};
|
||||
|
||||
const statusSizes = {
|
||||
xs: 'w-2 h-2',
|
||||
sm: 'w-2.5 h-2.5',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-3.5 h-3.5',
|
||||
xl: 'w-4 h-4',
|
||||
'2xl': 'w-5 h-5'
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
online: 'bg-success',
|
||||
offline: 'bg-light/30',
|
||||
away: 'bg-warning',
|
||||
busy: 'bg-error'
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function getColorFromName(name: string): string {
|
||||
const colors = [
|
||||
'bg-red-500',
|
||||
'bg-orange-500',
|
||||
'bg-amber-500',
|
||||
'bg-yellow-500',
|
||||
'bg-lime-500',
|
||||
'bg-green-500',
|
||||
'bg-emerald-500',
|
||||
'bg-teal-500',
|
||||
'bg-cyan-500',
|
||||
'bg-sky-500',
|
||||
'bg-blue-500',
|
||||
'bg-indigo-500',
|
||||
'bg-violet-500',
|
||||
'bg-purple-500',
|
||||
'bg-fuchsia-500',
|
||||
'bg-pink-500'
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[
|
||||
size
|
||||
]} {!src ? getColorFromName(name) : 'bg-surface'}"
|
||||
>
|
||||
{#if src}
|
||||
<img {src} alt={name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
{getInitials(name)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<div
|
||||
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[
|
||||
status
|
||||
]}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
30
src/lib/components/ui/Badge.svelte
Normal file
30
src/lib/components/ui/Badge.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', size = 'md', children }: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-light/10 text-light',
|
||||
primary: 'bg-primary/20 text-primary',
|
||||
success: 'bg-success/20 text-success',
|
||||
warning: 'bg-warning/20 text-warning',
|
||||
error: 'bg-error/20 text-error',
|
||||
info: 'bg-info/20 text-info'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-0.5 text-sm',
|
||||
lg: 'px-2.5 py-1 text-sm'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center font-medium rounded-full {variantClasses[variant]} {sizeClasses[size]}">
|
||||
{@render children()}
|
||||
</span>
|
||||
71
src/lib/components/ui/Button.svelte
Normal file
71
src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
fullWidth?: boolean;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = 'button',
|
||||
fullWidth = false,
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-white hover:bg-primary/90 focus:ring-primary rounded-xl',
|
||||
secondary:
|
||||
'bg-surface text-light border border-light/20 hover:bg-light/5 focus:ring-light/50 rounded-xl',
|
||||
ghost: 'bg-transparent text-light hover:bg-light/10 focus:ring-light/50 rounded-xl',
|
||||
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error rounded-xl',
|
||||
success: 'bg-success text-white hover:bg-success/90 focus:ring-success rounded-xl'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-2 py-1 text-xs gap-1',
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
md: 'px-4 py-2 text-sm gap-2',
|
||||
lg: 'px-6 py-3 text-base gap-2.5'
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}"
|
||||
class:w-full={fullWidth}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
28
src/lib/components/ui/Card.svelte
Normal file
28
src/lib/components/ui/Card.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'elevated' | 'outlined';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', padding = 'md', children }: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-surface',
|
||||
elevated: 'bg-surface shadow-lg shadow-black/20',
|
||||
outlined: 'bg-surface border border-light/10'
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl {variantClasses[variant]} {paddingClasses[padding]}">
|
||||
{@render children()}
|
||||
</div>
|
||||
66
src/lib/components/ui/Input.svelte
Normal file
66
src/lib/components/ui/Input.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type?: "text" | "password" | "email" | "url" | "search" | "number";
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
autocomplete?: AutoFill;
|
||||
oninput?: (e: Event) => void;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
type = "text",
|
||||
value = $bindable(""),
|
||||
placeholder = "",
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
disabled = false,
|
||||
required = false,
|
||||
autocomplete,
|
||||
oninput,
|
||||
onkeydown,
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
{type}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{required}
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
{onkeydown}
|
||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
||||
placeholder:text-light/40
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
class:border-error={error}
|
||||
class:focus:border-error={error}
|
||||
class:focus:ring-error={error}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p class="text-sm text-light/50">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
70
src/lib/components/ui/Modal.svelte
Normal file
70
src/lib/components/ui/Modal.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, title, size = 'md', children }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl'
|
||||
};
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-light/10">
|
||||
<h2 id="modal-title" class="text-lg font-semibold text-light">{title}</h2>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
68
src/lib/components/ui/Select.svelte
Normal file
68
src/lib/components/ui/Select.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
options: Option[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
options,
|
||||
label,
|
||||
placeholder = "Select...",
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<select
|
||||
id={inputId}
|
||||
bind:value
|
||||
{disabled}
|
||||
{required}
|
||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors appearance-none cursor-pointer"
|
||||
class:border-error={error}
|
||||
class:placeholder-shown={!value}
|
||||
>
|
||||
<option value="" disabled>{placeholder}</option>
|
||||
{#each options as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 40px;
|
||||
}
|
||||
</style>
|
||||
34
src/lib/components/ui/Spinner.svelte
Normal file
34
src/lib/components/ui/Spinner.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: 'primary' | 'light' | 'current';
|
||||
}
|
||||
|
||||
let { size = 'md', color = 'primary' }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'text-primary',
|
||||
light: 'text-light',
|
||||
current: 'text-current'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="animate-spin {sizeClasses[size]} {colorClasses[color]}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
66
src/lib/components/ui/Textarea.svelte
Normal file
66
src/lib/components/ui/Textarea.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
disabled = false,
|
||||
required = false,
|
||||
rows = 3,
|
||||
resize = 'vertical'
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
const resizeClasses = {
|
||||
none: 'resize-none',
|
||||
vertical: 'resize-y',
|
||||
horizontal: 'resize-x',
|
||||
both: 'resize'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
id={inputId}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{required}
|
||||
{rows}
|
||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
||||
placeholder:text-light/40
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors {resizeClasses[resize]}"
|
||||
class:border-error={error}
|
||||
class:focus:border-error={error}
|
||||
class:focus:ring-error={error}
|
||||
></textarea>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p class="text-sm text-light/50">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
40
src/lib/components/ui/Toggle.svelte
Normal file
40
src/lib/components/ui/Toggle.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked = $bindable(false), disabled = false, size = 'md', onchange }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { track: 'w-8 h-4', thumb: 'w-3 h-3', translate: 'translate-x-4' },
|
||||
md: { track: 'w-10 h-5', thumb: 'w-4 h-4', translate: 'translate-x-5' },
|
||||
lg: { track: 'w-12 h-6', thumb: 'w-5 h-5', translate: 'translate-x-6' }
|
||||
};
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled) {
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
{disabled}
|
||||
onclick={handleClick}
|
||||
class="relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed {sizeClasses[
|
||||
size
|
||||
].track} {checked ? 'bg-primary' : 'bg-light/20'}"
|
||||
>
|
||||
<span
|
||||
class="inline-block rounded-full bg-white shadow-sm transition-transform duration-200 {sizeClasses[
|
||||
size
|
||||
].thumb} {checked ? sizeClasses[size].translate : 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
10
src/lib/components/ui/index.ts
Normal file
10
src/lib/components/ui/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { default as Textarea } from './Textarea.svelte';
|
||||
export { default as Select } from './Select.svelte';
|
||||
export { default as Avatar } from './Avatar.svelte';
|
||||
export { default as Badge } from './Badge.svelte';
|
||||
export { default as Card } from './Card.svelte';
|
||||
export { default as Modal } from './Modal.svelte';
|
||||
export { default as Spinner } from './Spinner.svelte';
|
||||
export { default as Toggle } from './Toggle.svelte';
|
||||
Reference in New Issue
Block a user