First commit

This commit is contained in:
AlacrisDevs
2026-02-04 23:01:44 +02:00
commit cfec43f7ef
78 changed files with 9509 additions and 0 deletions

View 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>

View File

@@ -0,0 +1 @@
export { default as Calendar } from './Calendar.svelte';

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as FileTree } from './FileTree.svelte';
export { default as Editor } from './Editor.svelte';

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as KanbanBoard } from './KanbanBoard.svelte';
export { default as CardDetailModal } from './CardDetailModal.svelte';

View 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>

View 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>

View 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>

View 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>

View 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>

View 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}

View 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>

View 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>

View 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>

View 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>

View 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';