Mega push vol1
This commit is contained in:
@@ -1,60 +1,95 @@
|
||||
<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';
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
document?: Document | null;
|
||||
content?: object | null;
|
||||
editable?: boolean;
|
||||
placeholder?: string;
|
||||
onUpdate?: (content: object) => void;
|
||||
onSave?: () => void;
|
||||
onSave?: (content: object) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
document = null,
|
||||
content = null,
|
||||
editable = true,
|
||||
placeholder = 'Start writing...',
|
||||
placeholder = "Start writing...",
|
||||
onUpdate,
|
||||
onSave
|
||||
onSave,
|
||||
}: Props = $props();
|
||||
|
||||
// Use document content if provided, otherwise use content prop
|
||||
const initialContent = $derived(document?.content ?? content);
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let editor: Editor | null = $state(null);
|
||||
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function triggerAutoSave() {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(() => {
|
||||
if (editor && onSave) {
|
||||
onSave(editor.getJSON());
|
||||
}
|
||||
}, 1000); // Auto-save after 1 second of inactivity
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder })
|
||||
],
|
||||
content: content ?? undefined,
|
||||
extensions: [StarterKit, Placeholder.configure({ placeholder })],
|
||||
content: (initialContent as object) ?? undefined,
|
||||
editable,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate?.(editor.getJSON());
|
||||
const json = editor.getJSON();
|
||||
onUpdate?.(json);
|
||||
if (editable) triggerAutoSave();
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4'
|
||||
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') {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
onSave?.();
|
||||
if (editor && onSave) onSave(editor.getJSON());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
// Update editor when document changes
|
||||
$effect(() => {
|
||||
if (editor && initialContent) {
|
||||
const currentContent = JSON.stringify(editor.getJSON());
|
||||
const newContent = JSON.stringify(initialContent);
|
||||
if (currentContent !== newContent) {
|
||||
editor.commands.setContent(initialContent as object);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update editable state when prop changes
|
||||
$effect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
});
|
||||
|
||||
export function setContent(newContent: object | null) {
|
||||
if (editor && newContent) {
|
||||
editor.commands.setContent(newContent);
|
||||
@@ -72,14 +107,22 @@
|
||||
|
||||
<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">
|
||||
<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')}
|
||||
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">
|
||||
<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>
|
||||
@@ -87,10 +130,16 @@
|
||||
<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')}
|
||||
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">
|
||||
<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" />
|
||||
@@ -99,10 +148,16 @@
|
||||
<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')}
|
||||
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">
|
||||
<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" />
|
||||
@@ -111,24 +166,27 @@
|
||||
<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 })}
|
||||
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 })}
|
||||
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 })}
|
||||
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>
|
||||
@@ -137,10 +195,16 @@
|
||||
<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')}
|
||||
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">
|
||||
<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" />
|
||||
@@ -151,23 +215,32 @@
|
||||
</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')}
|
||||
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">
|
||||
<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>
|
||||
<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')}
|
||||
class:text-primary={editor?.isActive("blockquote")}
|
||||
title="Quote"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -177,10 +250,16 @@
|
||||
<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')}
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
onSelect: (doc: DocumentWithChildren) => void;
|
||||
onAdd?: (parentId: string | null) => void;
|
||||
onMove?: (docId: string, newParentId: string | null) => void;
|
||||
onEdit?: (doc: DocumentWithChildren) => void;
|
||||
onDelete?: (doc: DocumentWithChildren) => void;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
@@ -16,6 +18,8 @@
|
||||
onSelect,
|
||||
onAdd,
|
||||
onMove,
|
||||
onEdit,
|
||||
onDelete,
|
||||
level = 0,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -145,24 +149,76 @@
|
||||
{/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"
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
|
||||
>
|
||||
{#if item.type === "folder" && onAdd}
|
||||
<button
|
||||
class="p-1 hover:bg-light/10 rounded"
|
||||
onclick={(e) => handleAdd(e, item.id)}
|
||||
aria-label="Add to folder"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<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}
|
||||
{#if onEdit}
|
||||
<button
|
||||
class="p-1 hover:bg-light/10 rounded"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(item);
|
||||
}}
|
||||
aria-label="Rename"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button
|
||||
class="p-1 hover:bg-error/20 hover:text-error rounded"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item);
|
||||
}}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3,6 5,6 21,6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if item.type === "folder" && expandedFolders.has(item.id)}
|
||||
@@ -174,6 +230,8 @@
|
||||
{onSelect}
|
||||
{onAdd}
|
||||
{onMove}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
level={level + 1}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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';
|
||||
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;
|
||||
@@ -19,52 +19,77 @@
|
||||
onClose: () => void;
|
||||
onUpdate: (card: KanbanCard) => void;
|
||||
onDelete: (cardId: string) => void;
|
||||
mode?: "edit" | "create";
|
||||
columnId?: string;
|
||||
userId?: string;
|
||||
onCreate?: (card: KanbanCard) => void;
|
||||
}
|
||||
|
||||
let { card, isOpen, onClose, onUpdate, onDelete }: Props = $props();
|
||||
let {
|
||||
card,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
mode = "edit",
|
||||
columnId,
|
||||
userId,
|
||||
onCreate,
|
||||
}: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>('supabase');
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let checklist = $state<ChecklistItem[]>([]);
|
||||
let newItemTitle = $state('');
|
||||
let newItemTitle = $state("");
|
||||
let isLoading = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (card && isOpen) {
|
||||
title = card.title;
|
||||
description = card.description ?? '';
|
||||
loadChecklist();
|
||||
if (isOpen) {
|
||||
if (mode === "edit" && card) {
|
||||
title = card.title;
|
||||
description = card.description ?? "";
|
||||
loadChecklist();
|
||||
} else if (mode === "create") {
|
||||
title = "";
|
||||
description = "";
|
||||
checklist = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadChecklist() {
|
||||
if (!card) return;
|
||||
isLoading = true;
|
||||
|
||||
|
||||
const { data } = await supabase
|
||||
.from('checklist_items')
|
||||
.select('*')
|
||||
.eq('card_id', card.id)
|
||||
.order('position');
|
||||
|
||||
.from("checklist_items")
|
||||
.select("*")
|
||||
.eq("card_id", card.id)
|
||||
.order("position");
|
||||
|
||||
checklist = (data ?? []) as ChecklistItem[];
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (mode === "create") {
|
||||
await handleCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!card) return;
|
||||
isSaving = true;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('kanban_cards')
|
||||
.update({
|
||||
title,
|
||||
description: description || null
|
||||
.from("kanban_cards")
|
||||
.update({
|
||||
title,
|
||||
description: description || null,
|
||||
})
|
||||
.eq('id', card.id);
|
||||
.eq("id", card.id);
|
||||
|
||||
if (!error) {
|
||||
onUpdate({ ...card, title, description: description || null });
|
||||
@@ -72,75 +97,108 @@
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!title.trim() || !columnId || !userId) return;
|
||||
isSaving = true;
|
||||
|
||||
const { data: column } = await supabase
|
||||
.from("kanban_columns")
|
||||
.select("cards:kanban_cards(count)")
|
||||
.eq("id", columnId)
|
||||
.single();
|
||||
|
||||
const position = (column as any)?.cards?.[0]?.count ?? 0;
|
||||
|
||||
const { data: newCard, error } = await supabase
|
||||
.from("kanban_cards")
|
||||
.insert({
|
||||
column_id: columnId,
|
||||
title,
|
||||
description: description || null,
|
||||
position,
|
||||
created_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && newCard) {
|
||||
onCreate?.(newCard as KanbanCard);
|
||||
onClose();
|
||||
}
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function handleAddItem() {
|
||||
if (!card || !newItemTitle.trim()) return;
|
||||
|
||||
const position = checklist.length;
|
||||
const { data, error } = await supabase
|
||||
.from('checklist_items')
|
||||
.from("checklist_items")
|
||||
.insert({
|
||||
card_id: card.id,
|
||||
title: newItemTitle,
|
||||
position,
|
||||
completed: false
|
||||
completed: false,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
checklist = [...checklist, data as ChecklistItem];
|
||||
newItemTitle = '';
|
||||
newItemTitle = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleItem(item: ChecklistItem) {
|
||||
const { error } = await supabase
|
||||
.from('checklist_items')
|
||||
.from("checklist_items")
|
||||
.update({ completed: !item.completed })
|
||||
.eq('id', item.id);
|
||||
.eq("id", item.id);
|
||||
|
||||
if (!error) {
|
||||
checklist = checklist.map(i =>
|
||||
i.id === item.id ? { ...i, completed: !i.completed } : i
|
||||
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')
|
||||
.from("checklist_items")
|
||||
.delete()
|
||||
.eq('id', itemId);
|
||||
.eq("id", itemId);
|
||||
|
||||
if (!error) {
|
||||
checklist = checklist.filter(i => i.id !== itemId);
|
||||
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);
|
||||
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);
|
||||
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}
|
||||
<Modal
|
||||
{isOpen}
|
||||
{onClose}
|
||||
title={mode === "create" ? "Add Card" : "Card Details"}
|
||||
size="lg"
|
||||
>
|
||||
{#if mode === "create" || card}
|
||||
<div class="space-y-5">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
placeholder="Card title"
|
||||
/>
|
||||
<Input label="Title" bind:value={title} placeholder="Card title" />
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
@@ -151,15 +209,21 @@
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="text-sm font-medium text-light">Checklist</label>
|
||||
<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>
|
||||
<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
|
||||
<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>
|
||||
@@ -174,16 +238,28 @@
|
||||
<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'}"
|
||||
{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">
|
||||
<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'}">
|
||||
<span
|
||||
class="flex-1 text-sm {item.completed
|
||||
? 'line-through text-light/40'
|
||||
: 'text-light'}"
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
@@ -191,7 +267,13 @@
|
||||
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">
|
||||
<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>
|
||||
@@ -206,23 +288,38 @@
|
||||
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()}
|
||||
onkeydown={(e) =>
|
||||
e.key === "Enter" && handleAddItem()}
|
||||
/>
|
||||
<Button size="sm" onclick={handleAddItem} disabled={!newItemTitle.trim()}>
|
||||
<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 items-center justify-between pt-3 border-t border-light/10"
|
||||
>
|
||||
{#if mode === "edit"}
|
||||
<Button variant="danger" onclick={handleDelete}>
|
||||
Delete Card
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
||||
<Button onclick={handleSave} loading={isSaving}>
|
||||
Save Changes
|
||||
<Button
|
||||
onclick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
{mode === "create" ? "Add Card" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
133
src/lib/components/kanban/KanbanCard.svelte
Normal file
133
src/lib/components/kanban/KanbanCard.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
|
||||
import { Badge } from "$lib/components/ui";
|
||||
|
||||
// Extended card type with optional new fields from migration
|
||||
interface ExtendedCard extends KanbanCardType {
|
||||
priority?: "low" | "medium" | "high" | "urgent" | null;
|
||||
assignee_id?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: ExtendedCard;
|
||||
isDragging?: boolean;
|
||||
onclick?: () => void;
|
||||
draggable?: boolean;
|
||||
ondragstart?: (e: DragEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
card,
|
||||
isDragging = false,
|
||||
onclick,
|
||||
draggable = true,
|
||||
ondragstart,
|
||||
}: Props = $props();
|
||||
|
||||
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 getDueDateVariant(
|
||||
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";
|
||||
}
|
||||
|
||||
function getPriorityColor(priority: string | null): string {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "#E03D00";
|
||||
case "high":
|
||||
return "#FFAB00";
|
||||
case "medium":
|
||||
return "#00A3E0";
|
||||
case "low":
|
||||
return "#33E000";
|
||||
default:
|
||||
return "#E5E6F0";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-night rounded-[16px] p-3 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group"
|
||||
class:opacity-50={isDragging}
|
||||
{draggable}
|
||||
{ondragstart}
|
||||
{onclick}
|
||||
onkeydown={(e) => e.key === "Enter" && onclick?.()}
|
||||
role="listitem"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Priority indicator -->
|
||||
{#if card.priority}
|
||||
<div
|
||||
class="w-full h-1 rounded-full mb-2"
|
||||
style="background-color: {getPriorityColor(card.priority)}"
|
||||
></div>
|
||||
{:else if card.color}
|
||||
<div
|
||||
class="w-full h-1 rounded-full mb-2"
|
||||
style="background-color: {card.color}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Title -->
|
||||
<p class="text-sm font-medium text-light">{card.title}</p>
|
||||
|
||||
<!-- Description -->
|
||||
{#if card.description}
|
||||
<p class="text-xs text-light/50 mt-1 line-clamp-2">
|
||||
{card.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Footer with metadata -->
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<!-- Due date -->
|
||||
{#if card.due_date}
|
||||
<Badge size="sm" variant={getDueDateVariant(card.due_date)}>
|
||||
<svg
|
||||
class="w-3 h-3 mr-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{formatDueDate(card.due_date)}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<!-- Assignee placeholder -->
|
||||
{#if card.assignee_id}
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium"
|
||||
>
|
||||
A
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
||||
export { default as KanbanCard } from './KanbanCard.svelte';
|
||||
|
||||
@@ -1,45 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
type?: "button" | "submit" | "reset";
|
||||
fullWidth?: boolean;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = 'button',
|
||||
type = "button",
|
||||
fullWidth = false,
|
||||
onclick,
|
||||
children
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
// Figma-matched base styles
|
||||
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';
|
||||
"inline-flex items-center justify-center font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-30 disabled:cursor-not-allowed rounded-[32px]";
|
||||
|
||||
// Figma-matched variant styles
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-white hover:bg-primary/90 focus:ring-primary rounded-xl',
|
||||
primary:
|
||||
"bg-primary text-night hover:brightness-110 active:brightness-90",
|
||||
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'
|
||||
"border-2 border-primary text-primary bg-transparent hover:bg-primary/10 active:bg-primary/20",
|
||||
ghost: "bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
|
||||
danger: "bg-error text-night hover:brightness-110 active:brightness-90",
|
||||
success:
|
||||
"bg-success text-night hover:brightness-110 active:brightness-90",
|
||||
};
|
||||
|
||||
// Figma-matched size styles (px values from Figma)
|
||||
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'
|
||||
sm: "px-3 py-1.5 text-sm gap-1.5 min-w-[96px]",
|
||||
md: "px-4 py-2 text-base gap-2 min-w-[128px]",
|
||||
lg: "px-5 py-3 text-xl gap-2.5 min-w-[128px]",
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'elevated' | 'outlined';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
variant?: "default" | "elevated" | "outlined";
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', padding = 'md', children }: Props = $props();
|
||||
let {
|
||||
variant = "default",
|
||||
padding = "md",
|
||||
class: className = "",
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
// Figma-matched styles: rounded-[32px], bg-night (#0A121F)
|
||||
const variantClasses = {
|
||||
default: 'bg-surface',
|
||||
elevated: 'bg-surface shadow-lg shadow-black/20',
|
||||
outlined: 'bg-surface border border-light/10'
|
||||
default: "bg-night",
|
||||
elevated: "bg-night shadow-lg shadow-black/30",
|
||||
outlined: "bg-night border border-light/10",
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
none: "",
|
||||
sm: "p-3",
|
||||
md: "p-5",
|
||||
lg: "p-6",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl {variantClasses[variant]} {paddingClasses[padding]}">
|
||||
<div
|
||||
class="rounded-[32px] {variantClasses[variant]} {paddingClasses[
|
||||
padding
|
||||
]} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -27,40 +27,78 @@
|
||||
onkeydown,
|
||||
}: Props = $props();
|
||||
|
||||
let showPassword = $state(false);
|
||||
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const isPassword = $derived(type === "password");
|
||||
const inputType = $derived(isPassword && showPassword ? "text" : type);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
<label for={inputId} class="px-3 font-heading text-xl text-white">
|
||||
{#if required}<span class="text-error">* </span>{/if}{label}
|
||||
</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}
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{required}
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
{onkeydown}
|
||||
class="w-full px-3 py-3 bg-night text-white rounded-[32px] min-w-[192px]
|
||||
placeholder:text-white/40
|
||||
focus:outline-none focus:ring-2 focus:ring-primary
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
class:ring-1={error}
|
||||
class:ring-error={error}
|
||||
/>
|
||||
{#if isPassword}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-white transition-colors"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
>
|
||||
{#if showPassword}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||
/>
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
<p class="text-sm text-error px-3">{error}</p>
|
||||
{:else if hint}
|
||||
<p class="text-sm text-light/50">{hint}</p>
|
||||
<p class="text-sm text-white/50 px-3">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
67
src/lib/components/ui/Toast.svelte
Normal file
67
src/lib/components/ui/Toast.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'success' | 'error' | 'warning' | 'info';
|
||||
title?: string;
|
||||
message?: string;
|
||||
onClose?: () => void;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'info',
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
success: 'bg-[#33e000]',
|
||||
error: 'bg-error',
|
||||
warning: 'bg-warning',
|
||||
info: 'bg-primary'
|
||||
};
|
||||
|
||||
const defaultTitles = {
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
info: 'Info'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex gap-4 items-start p-4 rounded-[32px] w-full max-w-lg {variantClasses[variant]}">
|
||||
<svg class="w-9 h-9 shrink-0 text-night" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d={icons[variant]} />
|
||||
</svg>
|
||||
|
||||
<div class="flex-1 flex flex-col gap-1 text-night">
|
||||
<p class="font-heading text-xl">{title || defaultTitles[variant]}</p>
|
||||
{#if message}
|
||||
<p class="text-base">{message}</p>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onClose}
|
||||
<button
|
||||
class="shrink-0 text-night/50 hover:text-night transition-colors"
|
||||
onclick={onClose}
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -8,3 +8,4 @@ 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';
|
||||
export { default as Toast } from './Toast.svelte';
|
||||
|
||||
Reference in New Issue
Block a user