Mega push vol 4

This commit is contained in:
AlacrisDevs
2026-02-06 16:08:40 +02:00
parent b517bb975c
commit d8bbfd9dc3
95 changed files with 8019 additions and 3946 deletions

View File

@@ -22,31 +22,20 @@
let currentView = $state<ViewType>(initialView);
const today = new Date();
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const weekDayHeaders = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
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();
}
// Group days into weeks (rows of 7)
const weeks = $derived.by(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
result.push(days.slice(i, i + 7));
}
return result;
});
function getEventsForDay(date: Date): CalendarEvent[] {
return events.filter((event) => {
@@ -66,10 +55,12 @@
}),
);
// Get week days for week view
// Get week days for week view (Mon-Sun)
function getWeekDays(date: Date): Date[] {
const startOfWeek = new Date(date);
startOfWeek.setDate(date.getDate() - date.getDay());
const dayOfWeek = startOfWeek.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
startOfWeek.setDate(date.getDate() + mondayOffset);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(startOfWeek);
d.setDate(startOfWeek.getDate() + i);
@@ -79,7 +70,6 @@
const weekDates = $derived(getWeekDays(currentDate));
// Navigation functions for different views
function prev() {
if (currentView === "month") {
currentDate = new Date(
@@ -112,7 +102,11 @@
}
}
const headerTitle = $derived(() => {
function goToToday() {
currentDate = new Date();
}
const headerTitle = $derived.by(() => {
if (currentView === "day") {
return currentDate.toLocaleDateString("en-US", {
weekday: "long",
@@ -129,207 +123,200 @@
});
</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">{headerTitle()}</h2>
<div class="flex flex-col h-full gap-2">
<!-- Navigation bar -->
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<!-- View Switcher -->
<div class="flex bg-dark rounded-lg p-0.5">
<button
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
'day'
? 'bg-primary text-white'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "day")}
>
Day
</button>
<button
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
'week'
? 'bg-primary text-white'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "week")}
>
Week
</button>
<button
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
'month'
? 'bg-primary text-white'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "month")}
>
Month
</button>
</div>
<button
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
onclick={prev}
aria-label="Previous"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_left</span
>
</button>
<span
class="font-heading text-h4 text-white min-w-[200px] text-center"
>{headerTitle}</span
>
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
onclick={next}
aria-label="Next"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_right</span
>
</button>
<button
class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2"
onclick={goToToday}
>
Today
</button>
</div>
<div class="flex bg-dark rounded-[32px] p-0.5">
<button
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={prev}
aria-label="Previous"
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
'day'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "day")}>Day</button
>
<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={next}
aria-label="Next"
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
'week'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "week")}>Week</button
>
<button
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
'month'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "month")}>Month</button
>
<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>
<!-- Month View -->
{#if currentView === "month"}
<div
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
{#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">
<!-- Day Headers -->
<div class="grid grid-cols-7 gap-2">
{#each weekDayHeaders as day}
<div class="flex items-center justify-center py-2 px-2">
<span
class="text-sm {isToday
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center'
: 'text-light/80'}"
class="font-heading text-h4 text-white text-center"
>{day}</span
>
{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);
}}
{/each}
</div>
<!-- Calendar Grid -->
<div class="flex-1 flex flex-col gap-2 min-h-0">
{#each weeks as week}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each week as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<div
class="bg-night rounded-none flex flex-col items-start px-4 py-5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
{!inMonth ? 'opacity-50' : ''}"
onclick={() => onDateClick?.(day)}
>
{event.title}
</button>
<span
class="font-body text-body text-white {isToday
? 'text-primary font-bold'
: ''}"
>
{day.getDate()}
</span>
{#each dayEvents.slice(0, 2) as event}
<button
class="w-full mt-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
style="background-color: {event.color ??
'#00A3E0'}"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
>
{event.title}
</button>
{/each}
{#if dayEvents.length > 2}
<span
class="text-body-sm text-light/40 mt-0.5"
>+{dayEvents.length - 2} more</span
>
{/if}
</div>
{/each}
{#if dayEvents.length > 3}
<p class="text-xs text-light/40 px-1">
+{dayEvents.length - 3} more
</p>
{/if}
</div>
</button>
{/each}
{/each}
</div>
</div>
{/if}
<!-- Week View -->
{#if currentView === "week"}
<div
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="bg-dark">
<div class="px-2 py-2 text-center border-b border-light/10">
<div class="text-xs text-light/50">
{weekDays[day.getDay()]}
</div>
<div
class="text-lg font-medium {isToday
? 'text-primary'
: 'text-light'}"
>
{day.getDate()}
</div>
</div>
<div class="min-h-[300px] p-1 space-y-1">
{#each dayEvents as event}
<button
class="w-full text-xs px-2 py-1.5 rounded text-left"
style="background-color: {event.color ??
'#6366f1'}20; color: {event.color ??
'#6366f1'}"
onclick={() => onEventClick?.(event)}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="flex flex-col overflow-hidden">
<div class="px-4 py-3 text-center">
<div
class="font-heading text-h4 {isToday
? 'text-primary'
: 'text-white'}"
>
<div class="font-medium truncate">
{weekDayHeaders[(day.getDay() + 6) % 7]}
</div>
<div
class="font-body text-body-md {isToday
? 'text-primary'
: 'text-light/60'}"
>
{day.getDate()}
</div>
</div>
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
{#each dayEvents as event}
<button
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
style="background-color: {event.color ??
'#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
{event.title}
</div>
<div class="text-[10px] opacity-70">
{new Date(
event.start_time,
).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</div>
</button>
{/each}
</button>
{/each}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
{/if}
<!-- Day View -->
{#if currentView === "day"}
{@const dayEvents = getEventsForDay(currentDate)}
<div class="bg-dark rounded-lg p-4 min-h-[400px]">
<div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto">
{#if dayEvents.length === 0}
<div class="text-center text-light/40 py-12">
<p>No events for this day</p>
<p class="font-body text-body">No events for this day</p>
</div>
{:else}
<div class="space-y-2">
{#each dayEvents as event}
<button
class="w-full text-left p-3 rounded-lg transition-colors hover:opacity-80"
class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80"
style="background-color: {event.color ??
'#6366f1'}20; border-left: 3px solid {event.color ??
'#6366f1'}"
'#00A3E0'}20; border-left: 3px solid {event.color ??
'#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
<div class="font-medium text-light">
<div class="font-heading text-h5 text-white">
{event.title}
</div>
<div class="text-sm text-light/60 mt-1">
<div
class="font-body text-body-md text-light/60 mt-1"
>
{new Date(event.start_time).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
@@ -340,7 +327,9 @@
)}
</div>
{#if event.description}
<div class="text-sm text-light/50 mt-2">
<div
class="font-body text-body-md text-light/50 mt-2"
>
{event.description}
</div>
{/if}

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui";
import { Editor } from "$lib/components/documents";
import type { Document, Json } from "$lib/supabase/types";
interface Props {
document: Document;
onSave?: (content: Json) => void;
/** "preview" = read-only with Edit button that navigates to editUrl. "edit" = editable inline. */
mode?: "preview" | "edit";
/** URL to navigate to when clicking "+ Edit" in preview mode */
editUrl?: string;
/** Whether the document is locked by another user */
locked?: boolean;
/** Name of the user who holds the lock */
lockedByName?: string | null;
}
let {
document,
onSave,
mode = "preview",
editUrl,
locked = false,
lockedByName = null,
}: Props = $props();
let isEditing = $state(false);
$effect(() => {
isEditing = mode === "edit" && !locked;
});
function handleEditClick() {
if (locked) return;
if (mode === "preview" && editUrl) {
goto(editUrl);
} else {
isEditing = !isEditing;
}
}
</script>
<div
class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full"
>
<!-- Lock Banner -->
{#if locked}
<div
class="flex items-center gap-2 px-4 py-2.5 bg-warning/10 border-b border-warning/20"
>
<span
class="material-symbols-rounded text-warning"
style="font-size: 20px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
lock
</span>
<span class="text-body-sm text-warning">
{lockedByName || "Someone"} is currently editing this document. View-only
mode.
</span>
</div>
{/if}
<!-- Header -->
<header class="flex items-center gap-2 px-4 py-5">
<h2 class="flex-1 font-heading text-h1 text-white truncate">
{document.name}
</h2>
{#if locked}
<Button size="md" disabled>
<span
class="material-symbols-rounded mr-1"
style="font-size: 16px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>lock</span
>
Locked
</Button>
{:else if mode === "edit"}
<Button size="md" onclick={handleEditClick}>
{isEditing ? "Preview" : "Edit"}
</Button>
{:else}
<Button size="md" onclick={handleEditClick}>Edit</Button>
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
</header>
<!-- Editor Area -->
<div class="flex-1 bg-background rounded-[32px] mx-4 mb-4 overflow-auto">
<Editor {document} {onSave} editable={isEditing} />
</div>
</div>

View File

@@ -3,15 +3,15 @@
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import type { Document } from "$lib/supabase/types";
import type { Document, Json } from "$lib/supabase/types";
interface Props {
document?: Document | null;
content?: object | null;
editable?: boolean;
placeholder?: string;
onUpdate?: (content: object) => void;
onSave?: (content: object) => void;
onUpdate?: (content: Json) => void;
onSave?: (content: Json) => void;
}
let {
@@ -29,6 +29,7 @@
let element: HTMLDivElement;
let editor: Editor | null = $state(null);
let saveStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
let isMounted = $state(true);
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -37,24 +38,25 @@
if (saveTimeout) clearTimeout(saveTimeout);
saveStatus = "idle";
saveTimeout = setTimeout(async () => {
await saveNow();
if (isMounted) await saveNow();
}, 1000); // Auto-save after 1 second of inactivity
}
async function saveNow() {
if (editor && onSave) {
saveStatus = "saving";
try {
await onSave(editor.getJSON());
saveStatus = "saved";
// Reset status after 2 seconds
if (statusTimeout) clearTimeout(statusTimeout);
statusTimeout = setTimeout(() => {
saveStatus = "idle";
}, 2000);
} catch {
saveStatus = "error";
}
if (!isMounted || !editor || !onSave) return;
saveStatus = "saving";
try {
await onSave(editor.getJSON());
if (!isMounted) return; // Guard after async
saveStatus = "saved";
// Reset status after 2 seconds
if (statusTimeout) clearTimeout(statusTimeout);
statusTimeout = setTimeout(() => {
if (isMounted) saveStatus = "idle";
}, 2000);
} catch {
if (isMounted) saveStatus = "error";
}
}
@@ -71,7 +73,7 @@
},
editorProps: {
attributes: {
class: "prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4",
class: "prose prose-invert max-w-3xl mx-auto focus:outline-none min-h-[200px] p-4",
},
handleKeyDown: (view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
@@ -86,6 +88,7 @@
});
onDestroy(() => {
isMounted = false;
if (saveTimeout) clearTimeout(saveTimeout);
if (statusTimeout) clearTimeout(statusTimeout);
editor?.destroy();
@@ -124,11 +127,9 @@
}
</script>
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
<div class="bg-background rounded-xl 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 bg-background">
<!-- Save Button -->
<button
class="flex items-center gap-1.5 px-2 py-1 mr-2 text-xs rounded hover:bg-light/10 transition-colors {saveStatus ===
@@ -346,7 +347,7 @@
</button>
</div>
{/if}
<div bind:this={element}></div>
<div class="border-none" bind:this={element}></div>
</div>
<style>

View File

@@ -0,0 +1,874 @@
<script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import {
Button,
Modal,
Input,
Avatar,
IconButton,
Icon,
} from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { createLogger } from "$lib/utils/logger";
import { toasts } from "$lib/stores/toast.svelte";
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
const log = createLogger("component.file-browser");
interface Props {
org: { id: string; name: string; slug: string };
documents: Document[];
currentFolderId: string | null;
user: { id: string } | null;
/** Page title shown in the header */
title?: string;
}
let {
org,
documents = $bindable(),
currentFolderId,
user,
title = "Files",
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let selectedDoc = $state<Document | null>(null);
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingDoc = $state<Document | null>(null);
let newDocName = $state("");
let newDocType = $state<"folder" | "document" | "kanban">("document");
let viewMode = $state<"list" | "grid">("grid");
// Context menu state
let contextMenu = $state<{ x: number; y: number; doc: Document } | null>(
null,
);
let showOrganizeMenu = $state(false);
// Sort: folders first, then documents, then kanbans, alphabetical
function typeOrder(type: string): number {
if (type === "folder") return 0;
if (type === "document") return 1;
if (type === "kanban") return 2;
return 3;
}
const currentFolderItems = $derived(
documents
.filter((d) =>
currentFolderId === null
? d.parent_id === null
: d.parent_id === currentFolderId,
)
.sort((a, b) => {
const typeA = typeOrder(a.type);
const typeB = typeOrder(b.type);
if (typeA !== typeB) return typeA - typeB;
return a.name.localeCompare(b.name);
}),
);
// Drag and drop state
let draggedItem = $state<Document | null>(null);
let dragOverFolder = $state<string | null>(null);
let isDragging = $state(false);
let dragOverBreadcrumb = $state<string | null | undefined>(undefined);
// Build breadcrumb path
const breadcrumbPath = $derived.by(() => {
const path: { id: string | null; name: string }[] = [
{ id: null, name: "Home" },
];
if (currentFolderId === null) return path;
let current = documents.find((d) => d.id === currentFolderId);
const ancestors: { id: string; name: string }[] = [];
while (current) {
ancestors.unshift({ id: current.id, name: current.name });
current = current.parent_id
? documents.find((d) => d.id === current!.parent_id)
: undefined;
}
return [...path, ...ancestors];
});
// URL helpers
function getFolderUrl(folderId: string | null): string {
if (!folderId) return `/${org.slug}/documents`;
return `/${org.slug}/documents/folder/${folderId}`;
}
function getFileUrl(doc: Document): string {
return `/${org.slug}/documents/file/${doc.id}`;
}
function getDocIcon(doc: Document): string {
if (doc.type === "folder") return "folder";
if (doc.type === "kanban") return "view_kanban";
return "description";
}
function handleItemClick(doc: Document) {
if (isDragging) {
isDragging = false;
return;
}
if (doc.type === "folder") {
goto(getFolderUrl(doc.id));
} else if (doc.type === "kanban") {
goto(getFileUrl(doc));
} else {
selectedDoc = doc;
}
}
function handleDoubleClick(doc: Document) {
if (doc.type === "folder") {
window.open(getFolderUrl(doc.id), "_blank");
} else {
window.open(getFileUrl(doc), "_blank");
}
}
function handleAuxClick(e: MouseEvent, doc: Document) {
if (e.button === 1) {
e.preventDefault();
if (doc.type === "folder") {
window.open(getFolderUrl(doc.id), "_blank");
} else {
window.open(getFileUrl(doc), "_blank");
}
}
}
// Context menu handlers
function handleContextMenu(e: MouseEvent, doc: Document) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, doc };
showOrganizeMenu = false;
}
function closeContextMenu() {
contextMenu = null;
showOrganizeMenu = false;
}
function contextRename() {
if (!contextMenu) return;
editingDoc = contextMenu.doc;
newDocName = contextMenu.doc.name;
showEditModal = true;
closeContextMenu();
}
async function contextCopy() {
if (!contextMenu || !user) return;
const doc = contextMenu.doc;
closeContextMenu();
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: `${doc.name} (copy)`,
type: doc.type,
parent_id: doc.parent_id,
created_by: user.id,
content: doc.content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
toasts.success(`Copied "${doc.name}"`);
} else if (error) {
log.error("Failed to copy document", { error });
toasts.error("Failed to copy document");
}
}
function contextOrganize() {
showOrganizeMenu = !showOrganizeMenu;
}
async function contextMoveToFolder(folderId: string | null) {
if (!contextMenu) return;
const doc = contextMenu.doc;
closeContextMenu();
await handleMove(doc.id, folderId);
toasts.success(
`Moved "${doc.name}" to ${folderId ? (documents.find((d) => d.id === folderId)?.name ?? "folder") : "Home"}`,
);
}
function contextDelete() {
if (!contextMenu) return;
const doc = contextMenu.doc;
closeContextMenu();
handleDelete(doc);
}
const availableFolders = $derived(
documents.filter(
(d) => d.type === "folder" && d.id !== contextMenu?.doc.id,
),
);
function handleAdd() {
showCreateModal = true;
}
// Drag handlers
function handleDragStart(e: DragEvent, doc: Document) {
isDragging = true;
draggedItem = doc;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", doc.id);
}
}
function handleDragEnd() {
resetDragState();
}
function handleDragOver(e: DragEvent, doc: Document) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (draggedItem?.id === doc.id) return;
if (doc.type === "folder") {
dragOverFolder = doc.id;
} else {
dragOverFolder = null;
}
}
function handleDragLeave() {
dragOverFolder = null;
}
async function handleDrop(e: DragEvent, targetDoc: Document) {
e.preventDefault();
e.stopPropagation();
if (!draggedItem || draggedItem.id === targetDoc.id) {
resetDragState();
return;
}
if (targetDoc.type === "folder") {
const draggedName = draggedItem.name;
await handleMove(draggedItem.id, targetDoc.id);
toasts.success(`Moved "${draggedName}" into "${targetDoc.name}"`);
}
resetDragState();
}
function handleContainerDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
}
async function handleDropOnEmpty(e: DragEvent) {
e.preventDefault();
if (!draggedItem) return;
if (draggedItem.parent_id !== currentFolderId) {
await handleMove(draggedItem.id, currentFolderId);
}
resetDragState();
}
function resetDragState() {
draggedItem = null;
dragOverFolder = null;
setTimeout(() => {
isDragging = false;
}, 100);
}
async function handleMove(docId: string, newParentId: string | null) {
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (error) {
log.error("Failed to move document", {
error,
data: { docId, newParentId },
});
toasts.error("Failed to move file");
const { data: freshDocs } = await supabase
.from("documents")
.select("*")
.eq("org_id", org.id)
.order("name");
if (freshDocs) documents = freshDocs as Document[];
}
}
async function handleCreate() {
if (!newDocName.trim() || !user) return;
if (newDocType === "kanban") {
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: org.id, name: newDocName })
.select()
.single();
if (boardError || !newBoard) {
toasts.error("Failed to create kanban board");
return;
}
await supabase.from("kanban_columns").insert([
{ board_id: newBoard.id, name: "To Do", position: 0 },
{ board_id: newBoard.id, name: "In Progress", position: 1 },
{ board_id: newBoard.id, name: "Done", position: 2 },
]);
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
id: newBoard.id,
org_id: org.id,
name: newDocName,
type: "kanban",
parent_id: currentFolderId,
created_by: user.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
})
.select()
.single();
if (!error && newDoc) {
goto(getFileUrl(newDoc as Document));
} else if (error) {
toasts.error("Failed to create kanban document");
}
} else {
let content: any = null;
if (newDocType === "document") {
content = { type: "doc", content: [] };
}
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: newDocName,
type: newDocType as "folder" | "document",
parent_id: currentFolderId,
created_by: user.id,
content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
if (newDocType === "document") {
goto(getFileUrl(newDoc as Document));
}
} else if (error) {
toasts.error("Failed to create document");
}
}
showCreateModal = false;
newDocName = "";
newDocType = "document";
}
async function handleSave(content: import("$lib/supabase/types").Json) {
if (!selectedDoc) return;
await supabase
.from("documents")
.update({ content, updated_at: new Date().toISOString() })
.eq("id", selectedDoc.id);
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
}
async function handleRename() {
if (!editingDoc || !newDocName.trim()) return;
const { error } = await supabase
.from("documents")
.update({ name: newDocName, updated_at: new Date().toISOString() })
.eq("id", editingDoc.id);
if (!error) {
documents = documents.map((d) =>
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
);
if (selectedDoc?.id === editingDoc.id) {
selectedDoc = { ...selectedDoc, name: newDocName };
}
}
showEditModal = false;
editingDoc = null;
newDocName = "";
}
async function handleDelete(doc: Document) {
const itemType =
doc.type === "folder" ? "folder and all its contents" : "document";
if (!confirm(`Delete this ${itemType}?`)) return;
// Recursively collect all descendant IDs for proper deletion
function collectDescendantIds(parentId: string): string[] {
const children = documents.filter((d) => d.parent_id === parentId);
let ids: string[] = [];
for (const child of children) {
ids.push(child.id);
if (child.type === "folder") {
ids = ids.concat(collectDescendantIds(child.id));
}
}
return ids;
}
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
if (descendantIds.length > 0) {
await supabase
.from("documents")
.delete()
.in("id", descendantIds);
}
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
const deletedIds = new Set([
doc.id,
...(doc.type === "folder" ? collectDescendantIds(doc.id) : []),
]);
documents = documents.filter((d) => !deletedIds.has(d.id));
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
}
}
</script>
<div class="flex h-full gap-4">
<!-- Files Panel -->
<div
class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full"
>
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name={title} size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
<Button size="md" onclick={handleAdd}>+ New</Button>
<IconButton
title="Toggle view"
onclick={() =>
(viewMode = viewMode === "list" ? "grid" : "list")}
>
<Icon
name={viewMode === "list" ? "grid_view" : "view_list"}
size={24}
/>
</IconButton>
</header>
<!-- Breadcrumb Path -->
<nav class="flex items-center gap-2 text-h3 font-heading">
{#each breadcrumbPath as crumb, i}
{#if i > 0}
<span
class="material-symbols-rounded text-light/30"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
chevron_right
</span>
{/if}
<a
href={getFolderUrl(crumb.id)}
class="px-3 py-1 rounded-xl transition-colors
{crumb.id === currentFolderId
? 'text-white'
: 'text-light/60 hover:text-primary'}
{dragOverBreadcrumb === (crumb.id ?? '__root__')
? 'ring-2 ring-primary bg-primary/10'
: ''}"
ondragover={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
dragOverBreadcrumb = crumb.id ?? "__root__";
}}
ondragleave={() => {
dragOverBreadcrumb = undefined;
}}
ondrop={async (e) => {
e.preventDefault();
e.stopPropagation();
dragOverBreadcrumb = undefined;
if (!draggedItem) return;
if (draggedItem.parent_id === crumb.id) {
resetDragState();
return;
}
const draggedName = draggedItem.name;
await handleMove(draggedItem.id, crumb.id);
toasts.success(
`Moved "${draggedName}" to "${crumb.name}"`,
);
resetDragState();
}}
>
{crumb.name}
</a>
{/each}
</nav>
<!-- File List/Grid -->
<div class="flex-1 overflow-auto min-h-0">
{#if viewMode === "list"}
<div
class="flex flex-col gap-1"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>
No files yet. Drag files here or create a new
one.
</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] w-full text-left transition-colors hover:bg-dark
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, item)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, item)}
onclick={() => handleItemClick(item)}
ondblclick={() => handleDoubleClick(item)}
onauxclick={(e) => handleAuxClick(e, item)}
oncontextmenu={(e) =>
handleContextMenu(e, item)}
>
<div
class="w-8 h-8 flex items-center justify-center p-1"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{getDocIcon(item)}
</span>
</div>
<span
class="font-body text-body text-white truncate flex-1"
>{item.name}</span
>
{#if item.type === "folder"}
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
chevron_right
</span>
{/if}
</button>
{/each}
{/if}
</div>
{:else}
<!-- Grid View -->
<div
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div
class="col-span-full text-center text-light/40 py-8 text-sm"
>
<p>
No files yet. Drag files here or create a new
one.
</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex flex-col items-center gap-2 p-4 rounded-xl transition-colors hover:bg-dark
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, item)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, item)}
onclick={() => handleItemClick(item)}
ondblclick={() => handleDoubleClick(item)}
onauxclick={(e) => handleAuxClick(e, item)}
oncontextmenu={(e) =>
handleContextMenu(e, item)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
{getDocIcon(item)}
</span>
<span
class="font-body text-body-md text-white text-center truncate w-full"
>{item.name}</span
>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
<!-- Compact Editor Panel (shown when a doc is selected) -->
{#if selectedDoc}
<div class="flex-1 min-w-0 h-full">
<DocumentViewer
document={selectedDoc}
onSave={handleSave}
mode="preview"
editUrl={getFileUrl(selectedDoc)}
/>
</div>
{/if}
</div>
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
>
<div class="space-y-4">
<div class="flex gap-2">
<button
type="button"
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'document'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "document")}
>
<span
class="material-symbols-rounded text-h4 mr-1"
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>description</span
>
Document
</button>
<button
type="button"
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'folder'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "folder")}
>
<span
class="material-symbols-rounded text-h4 mr-1"
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
Folder
</button>
<button
type="button"
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'kanban'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "kanban")}
>
<span
class="material-symbols-rounded text-h4 mr-1"
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>view_kanban</span
>
Kanban
</button>
</div>
<Input
label="Name"
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
: newDocType === "kanban"
? "Kanban board name"
: "Document name"}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>
</div>
</div>
</Modal>
<!-- Context Menu -->
{#if contextMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50" onclick={closeContextMenu}></div>
<div
class="fixed z-50 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[200px]"
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={contextRename}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
Rename
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={contextCopy}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>content_copy</span
>
Make a copy
</button>
<div class="relative">
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={contextOrganize}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>drive_file_move</span
>
Organize
<span
class="material-symbols-rounded text-light/50 ml-auto"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>chevron_right</span
>
</button>
{#if showOrganizeMenu}
<div
class="absolute left-full top-0 ml-1 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[180px] max-h-[240px] overflow-auto"
>
{#if contextMenu.doc.parent_id !== null}
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={() => contextMoveToFolder(null)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>home</span
>
Home
</button>
{/if}
{#each availableFolders as folder}
{#if folder.id !== contextMenu.doc.parent_id}
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={() => contextMoveToFolder(folder.id)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
{folder.name}
</button>
{/if}
{/each}
</div>
{/if}
</div>
<div class="border-t border-light/10 my-1"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-error hover:bg-error/10 transition-colors"
onclick={contextDelete}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
Delete
</button>
</div>
{/if}
<Modal
isOpen={showEditModal}
onClose={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}
title="Rename"
>
<div class="space-y-4">
<Input
label="Name"
bind:value={newDocName}
placeholder="Enter new name"
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>
</div>
</div>
</Modal>

View File

@@ -1,253 +0,0 @@
<script lang="ts">
import type { DocumentWithChildren } from "$lib/api/documents";
interface Props {
items: DocumentWithChildren[];
selectedId?: string | null;
onSelect: (doc: DocumentWithChildren) => void;
onDoubleClick?: (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;
}
let {
items,
selectedId = null,
onSelect,
onDoubleClick,
onAdd,
onMove,
onEdit,
onDelete,
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)}
ondblclick={() => onDoubleClick?.(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>
<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"
>
<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)}
<div class="ml-4 border-l border-light/10 pl-2">
{#if item.children?.length}
<svelte:self
items={item.children}
{selectedId}
{onSelect}
{onAdd}
{onMove}
{onEdit}
{onDelete}
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

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

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { Button, Input, Icon } from "$lib/components/ui";
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 {
cardId: string;
items: ChecklistItem[];
onItemsChange: (items: ChecklistItem[]) => void;
}
let { cardId, items, onItemsChange }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let isMounted = $state(true);
let newItemTitle = $state("");
let isAdding = $state(false);
onDestroy(() => {
isMounted = false;
});
const completedCount = $derived(items.filter((i) => i.completed).length);
const progress = $derived(
items.length > 0 ? (completedCount / items.length) * 100 : 0,
);
async function handleAddItem() {
if (!newItemTitle.trim() || !isMounted) return;
isAdding = true;
const position = items.length;
const { data, error } = await supabase
.from("kanban_checklist_items")
.insert({
card_id: cardId,
title: newItemTitle.trim(),
position,
completed: false,
})
.select()
.single();
if (!isMounted) return;
if (!error && data) {
onItemsChange([...items, data as ChecklistItem]);
newItemTitle = "";
}
isAdding = false;
}
async function toggleItem(item: ChecklistItem) {
if (!isMounted) return;
// Optimistic update
const updated = items.map((i) =>
i.id === item.id ? { ...i, completed: !i.completed } : i,
);
onItemsChange(updated);
const { error } = await supabase
.from("kanban_checklist_items")
.update({ completed: !item.completed })
.eq("id", item.id);
if (error && isMounted) {
// Rollback on error
onItemsChange(items);
}
}
async function deleteItem(itemId: string) {
if (!isMounted) return;
const { error } = await supabase
.from("kanban_checklist_items")
.delete()
.eq("id", itemId);
if (!error && isMounted) {
onItemsChange(items.filter((i) => i.id !== itemId));
}
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-light">Checklist</h4>
<span class="text-xs text-light/50"
>{completedCount}/{items.length}</span
>
</div>
<!-- Progress bar -->
{#if items.length > 0}
<div class="h-1.5 bg-dark rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300"
style="width: {progress}%"
></div>
</div>
{/if}
<!-- Checklist items -->
<div class="space-y-1">
{#each items as item (item.id)}
<div class="flex items-center gap-2 group py-1">
<button
type="button"
class="w-4 h-4 rounded border flex items-center justify-center transition-colors {item.completed
? 'bg-primary border-primary'
: 'border-light/30 hover:border-primary'}"
onclick={() => toggleItem(item)}
>
{#if item.completed}
<Icon name="check" size={12} class="text-white" />
{/if}
</button>
<span
class="flex-1 text-sm {item.completed
? 'line-through text-light/40'
: 'text-light'}"
>
{item.title}
</span>
<button
type="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"
>
<Icon name="close" size={14} />
</button>
</div>
{/each}
</div>
<!-- Add item form -->
<form
class="flex gap-2 items-end"
onsubmit={(e) => {
e.preventDefault();
handleAddItem();
}}
>
<Input
placeholder="Add checklist item..."
bind:value={newItemTitle}
disabled={isAdding}
/>
<Button
type="submit"
size="md"
disabled={!newItemTitle.trim() || isAdding}
>
Add
</Button>
</form>
</div>

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { Button, Input, Icon, Avatar } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Comment {
id: string;
card_id: string;
user_id: string;
content: string;
created_at: string;
profiles?: { full_name: string | null; email: string };
}
interface Props {
cardId: string;
userId: string;
comments: Comment[];
onCommentsChange: (comments: Comment[]) => void;
}
let { cardId, userId, comments, onCommentsChange }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let isMounted = $state(true);
let newComment = $state("");
let isAdding = $state(false);
onDestroy(() => {
isMounted = false;
});
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
async function handleAddComment() {
if (!newComment.trim() || !isMounted) return;
isAdding = true;
const { data, error } = await supabase
.from("kanban_comments")
.insert({
card_id: cardId,
user_id: userId,
content: newComment.trim(),
})
.select(
`
id,
card_id,
user_id,
content,
created_at,
profiles:user_id (full_name, email)
`,
)
.single();
if (!isMounted) return;
if (!error && data) {
onCommentsChange([...comments, data as Comment]);
newComment = "";
}
isAdding = false;
}
async function deleteComment(commentId: string) {
if (!isMounted) return;
const { error } = await supabase
.from("kanban_comments")
.delete()
.eq("id", commentId);
if (!error && isMounted) {
onCommentsChange(comments.filter((c) => c.id !== commentId));
}
}
</script>
<div class="space-y-3">
<span class="px-3 font-bold font-body text-body text-white">Comments</span>
<!-- Comment list -->
{#if comments.length > 0}
<div class="space-y-3 max-h-48 overflow-y-auto">
{#each comments as comment (comment.id)}
<div class="flex gap-2 group">
<Avatar
name={comment.profiles?.full_name ||
comment.profiles?.email ||
"?"}
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-light truncate"
>
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40">
{formatDate(comment.created_at)}
</span>
{#if comment.user_id === userId}
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all ml-auto"
onclick={() => deleteComment(comment.id)}
aria-label="Delete comment"
>
<Icon name="close" size={12} />
</button>
{/if}
</div>
<p class="text-sm text-light/70 break-words">
{comment.content}
</p>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-light/40 text-center py-2">No comments yet</p>
{/if}
<!-- Add comment form -->
<form
class="flex gap-2 items-end"
onsubmit={(e) => {
e.preventDefault();
handleAddComment();
}}
>
<Input
placeholder="Add a comment..."
bind:value={newComment}
disabled={isAdding}
/>
<Button
type="submit"
size="md"
disabled={!newComment.trim() || isAdding}
>
Send
</Button>
</form>
</div>

View File

@@ -1,10 +1,23 @@
<script lang="ts">
import { getContext } from "svelte";
import { Modal, Button, Input, Textarea } from "$lib/components/ui";
import { getContext, onDestroy } from "svelte";
import {
Modal,
Button,
Input,
Textarea,
Select,
AssigneePicker,
Icon,
} 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";
let isMounted = $state(true);
onDestroy(() => {
isMounted = false;
});
interface ChecklistItem {
id: string;
card_id: string;
@@ -33,6 +46,12 @@
};
}
interface OrgTag {
id: string;
name: string;
color: string | null;
}
interface Props {
card: KanbanCard | null;
isOpen: boolean;
@@ -42,6 +61,7 @@
mode?: "edit" | "create";
columnId?: string;
userId?: string;
orgId?: string;
onCreate?: (card: KanbanCard) => void;
members?: Member[];
}
@@ -55,6 +75,7 @@
mode = "edit",
columnId,
userId,
orgId,
onCreate,
members = [],
}: Props = $props();
@@ -74,20 +95,35 @@
let isSaving = $state(false);
let showAssigneePicker = $state(false);
// Tags state
let orgTags = $state<OrgTag[]>([]);
let cardTagIds = $state<Set<string>>(new Set());
let newTagName = $state("");
let showTagInput = $state(false);
const TAG_COLORS = [
"#00A3E0",
"#33E000",
"#E03D00",
"#FFAB00",
"#A855F7",
"#EC4899",
"#6366F1",
];
$effect(() => {
if (isOpen) {
if (mode === "edit" && card) {
title = card.title;
description = card.description ?? "";
assigneeId = (card as any).assignee_id ?? null;
dueDate = (card as any).due_date
? new Date((card as any).due_date)
.toISOString()
.split("T")[0]
assigneeId = card.assignee_id ?? null;
dueDate = card.due_date
? new Date(card.due_date).toISOString().split("T")[0]
: "";
priority = (card as any).priority ?? "medium";
priority = card.priority ?? "medium";
loadChecklist();
loadComments();
loadTags();
} else if (mode === "create") {
title = "";
description = "";
@@ -96,12 +132,14 @@
priority = "medium";
checklist = [];
comments = [];
cardTagIds = new Set();
loadOrgTags();
}
}
});
async function loadChecklist() {
if (!card) return;
if (!card || !isMounted) return;
isLoading = true;
const { data } = await supabase
@@ -110,12 +148,13 @@
.eq("card_id", card.id)
.order("position");
if (!isMounted) return;
checklist = (data ?? []) as ChecklistItem[];
isLoading = false;
}
async function loadComments() {
if (!card) return;
if (!card || !isMounted) return;
const { data } = await supabase
.from("kanban_comments")
@@ -132,10 +171,75 @@
.eq("card_id", card.id)
.order("created_at", { ascending: true });
if (!isMounted) return;
comments = (data ?? []) as Comment[];
}
async function loadOrgTags() {
if (!orgId) return;
const { data } = await supabase
.from("tags")
.select("id, name, color")
.eq("org_id", orgId)
.order("name");
if (!isMounted) return;
orgTags = (data ?? []) as OrgTag[];
}
async function loadTags() {
await loadOrgTags();
if (!card) return;
const { data } = await supabase
.from("card_tags")
.select("tag_id")
.eq("card_id", card.id);
if (!isMounted) return;
cardTagIds = new Set((data ?? []).map((t) => t.tag_id));
}
async function toggleTag(tagId: string) {
if (!card) return;
if (cardTagIds.has(tagId)) {
await supabase
.from("card_tags")
.delete()
.eq("card_id", card.id)
.eq("tag_id", tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
} else {
await supabase
.from("card_tags")
.insert({ card_id: card.id, tag_id: tagId });
cardTagIds.add(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function createTag() {
if (!newTagName.trim() || !orgId) return;
const color = TAG_COLORS[orgTags.length % TAG_COLORS.length];
const { data: newTag, error } = await supabase
.from("tags")
.insert({ name: newTagName.trim(), org_id: orgId, color })
.select()
.single();
if (!error && newTag) {
orgTags = [...orgTags, newTag as OrgTag];
if (card) {
await supabase
.from("card_tags")
.insert({ card_id: card.id, tag_id: newTag.id });
cardTagIds.add(newTag.id);
cardTagIds = new Set(cardTagIds);
}
}
newTagName = "";
showTagInput = false;
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
await handleCreate();
return;
@@ -178,7 +282,7 @@
.eq("id", columnId)
.single();
const position = (column as any)?.cards?.[0]?.count ?? 0;
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
const { data: newCard, error } = await supabase
.from("kanban_cards")
@@ -186,6 +290,9 @@
column_id: columnId,
title,
description: description || null,
priority: priority || null,
due_date: dueDate || null,
assignee_id: assigneeId || null,
position,
created_by: userId,
})
@@ -320,133 +427,97 @@
rows={3}
/>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<!-- Assignee -->
<div class="relative">
<label class="block text-sm font-medium text-light mb-1"
>Assignee</label
>
<button
type="button"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-left text-sm flex items-center gap-2 hover:border-light/40 transition-colors"
onclick={() =>
(showAssigneePicker = !showAssigneePicker)}
>
{#if assigneeId && getAssignee(assigneeId)}
{@const assignee = getAssignee(assigneeId)}
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs text-primary"
>
{(assignee?.profiles.full_name ||
assignee?.profiles.email ||
"?")[0].toUpperCase()}
</div>
<span class="text-light truncate"
>{assignee?.profiles.full_name ||
assignee?.profiles.email}</span
>
{:else}
<div
class="w-6 h-6 rounded-full bg-light/10 flex items-center justify-center"
>
<svg
class="w-3 h-3 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
/>
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<span class="text-light/40">Unassigned</span>
{/if}
</button>
{#if showAssigneePicker}
<div
class="absolute top-full left-0 right-0 mt-1 bg-dark border border-light/20 rounded-lg shadow-lg z-10 max-h-48 overflow-y-auto"
<!-- Tags -->
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-2 block"
>Tags</span
>
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
type="button"
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2"
style="background-color: {cardTagIds.has(tag.id)
? tag.color || '#00A3E0'
: 'transparent'}; color: {cardTagIds.has(tag.id)
? '#0A121F'
: tag.color ||
'#00A3E0'}; border-color: {tag.color ||
'#00A3E0'};"
onclick={() => toggleTag(tag.id)}
>
{tag.name}
</button>
{/each}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
placeholder="Tag name"
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
>
Add
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
assigneeId = null;
showAssigneePicker = false;
showTagInput = false;
newTagName = "";
}}
>
<div
class="w-6 h-6 rounded-full bg-light/10"
></div>
Unassigned
Cancel
</button>
{#each members as member}
<button
class="w-full px-3 py-2 text-left text-sm hover:bg-light/5 flex items-center gap-2 {assigneeId ===
member.user_id
? 'bg-primary/10 text-primary'
: 'text-light'}"
onclick={() => {
assigneeId = member.user_id;
showAssigneePicker = false;
}}
>
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs"
>
{(member.profiles.full_name ||
member.profiles.email ||
"?")[0].toUpperCase()}
</div>
{member.profiles.full_name ||
member.profiles.email}
</button>
{/each}
</div>
{:else}
<button
type="button"
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
onclick={() => (showTagInput = true)}
>
+ New tag
</button>
{/if}
</div>
</div>
<!-- Due Date -->
<div>
<label
for="due-date"
class="block text-sm font-medium text-light mb-1"
>Due Date</label
>
<input
id="due-date"
type="date"
bind:value={dueDate}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
/>
</div>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
/>
<!-- Priority -->
<div>
<label
for="priority"
class="block text-sm font-medium text-light mb-1"
>Priority</label
>
<select
id="priority"
bind:value={priority}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<Input type="date" label="Due Date" bind:value={dueDate} />
<Select
label="Priority"
bind:value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]}
/>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-light"
>Checklist</label
<span class="px-3 font-bold font-body text-body text-white"
>Checklist</span
>
{#if checklist.length > 0}
<span class="text-xs text-light/50"
@@ -499,36 +570,26 @@
{item.title}
</span>
<button
type="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>
<Icon name="close" size={16} />
</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"
<div class="flex gap-2 items-end">
<Input
placeholder="Add an item..."
bind:value={newItemTitle}
onkeydown={(e) =>
e.key === "Enter" && handleAddItem()}
/>
<Button
size="sm"
size="md"
onclick={handleAddItem}
disabled={!newItemTitle.trim()}
>
@@ -541,8 +602,9 @@
<!-- Comments Section -->
{#if mode === "edit"}
<div>
<label class="block text-sm font-medium text-light mb-3"
>Comments</label
<span
class="px-3 font-bold font-body text-body text-white mb-3 block"
>Comments</span
>
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
{#each comments as comment}
@@ -550,8 +612,8 @@
<div
class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary"
>
{((comment.profiles as any)?.full_name ||
(comment.profiles as any)?.email ||
{(comment.profiles?.full_name ||
comment.profiles?.email ||
"?")[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
@@ -559,10 +621,8 @@
<span
class="text-sm font-medium text-light"
>
{(comment.profiles as any)
?.full_name ||
(comment.profiles as any)
?.email ||
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40"
@@ -583,17 +643,15 @@
</p>
{/if}
</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"
<div class="flex gap-2 items-end">
<Input
placeholder="Add a comment..."
bind:value={newComment}
onkeydown={(e) =>
e.key === "Enter" && handleAddComment()}
/>
<Button
size="sm"
size="md"
onclick={handleAddComment}
disabled={!newComment.trim()}
>
@@ -614,7 +672,7 @@
<div></div>
{/if}
<div class="flex gap-2">
<Button variant="ghost" onclick={onClose}>Cancel</Button>
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
<Button
onclick={handleSave}
loading={isSaving}

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { Input, Select, AssigneePicker, Badge } from "$lib/components/ui";
interface Member {
id: string;
user_id: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
assigneeId: string | null;
dueDate: string;
priority: string;
members: Member[];
onAssigneeChange: (id: string | null) => void;
onDueDateChange: (date: string) => void;
onPriorityChange: (priority: string) => void;
}
let {
assigneeId,
dueDate,
priority,
members,
onAssigneeChange,
onDueDateChange,
onPriorityChange,
}: Props = $props();
let dueDateLocal = $state("");
$effect(() => {
dueDateLocal = dueDate;
});
const priorityColors: Record<string, string> = {
low: "bg-green-500/20 text-green-400",
medium: "bg-yellow-500/20 text-yellow-400",
high: "bg-orange-500/20 text-orange-400",
urgent: "bg-red-500/20 text-red-400",
};
</script>
<div class="grid grid-cols-3 gap-3">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={onAssigneeChange}
/>
<Input
type="date"
label="Due Date"
bind:value={dueDateLocal}
onchange={() => onDueDateChange(dueDateLocal)}
/>
<Select
label="Priority"
value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]}
onchange={(e) =>
onPriorityChange((e.target as HTMLSelectElement).value)}
/>
</div>
<!-- Priority indicator pill -->
{#if priority && priority !== "medium"}
<div class="mt-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {priorityColors[
priority
] || priorityColors.medium}"
>
{priority.charAt(0).toUpperCase() + priority.slice(1)} Priority
</span>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
<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";
import KanbanCardComponent from "./KanbanCard.svelte";
interface Props {
columns: ColumnWithCards[];
@@ -29,15 +29,11 @@
canEdit = true,
}: Props = $props();
function handleDeleteCard(e: MouseEvent, cardId: string) {
e.stopPropagation();
if (confirm("Are you sure you want to delete this task?")) {
onDeleteCard?.(cardId);
}
}
let draggedCard = $state<KanbanCard | null>(null);
let dragOverColumn = $state<string | null>(null);
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>(
null,
);
function handleDragStart(e: DragEvent, card: KanbanCard) {
draggedCard = card;
@@ -47,272 +43,193 @@
}
}
function handleDragOver(e: DragEvent, columnId: string) {
function handleColumnDragOver(e: DragEvent, columnId: string) {
e.preventDefault();
dragOverColumn = columnId;
}
function handleDragLeave() {
function handleColumnDragLeave() {
dragOverColumn = null;
dragOverCardIndex = null;
}
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
e.preventDefault();
e.stopPropagation();
if (!draggedCard) return;
// Determine if we're in the top or bottom half of the card
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dropIndex = e.clientY < midY ? index : index + 1;
dragOverColumn = columnId;
dragOverCardIndex = { columnId, index: dropIndex };
}
function handleDrop(e: DragEvent, columnId: string) {
e.preventDefault();
const targetIndex = dragOverCardIndex;
dragOverColumn = null;
dragOverCardIndex = 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);
if (!draggedCard) return;
const column = columns.find((c) => c.id === columnId);
if (!column) {
draggedCard = null;
return;
}
let newPosition: number;
if (targetIndex && targetIndex.columnId === columnId) {
newPosition = targetIndex.index;
// If moving within the same column and the card is above the target, adjust
if (draggedCard.column_id === columnId) {
const currentIndex = column.cards.findIndex(
(c) => c.id === draggedCard!.id,
);
if (currentIndex !== -1 && currentIndex < newPosition) {
newPosition = Math.max(0, newPosition - 1);
}
// No-op if dropping in the same position
if (currentIndex === newPosition) {
draggedCard = null;
return;
}
}
} else {
newPosition = column.cards.length;
}
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] scrollbar-visible">
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll">
{#each columns as column}
<div
class="flex-shrink-0 w-72 bg-surface/80 backdrop-blur-sm rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)] border border-light/10 shadow-lg {dragOverColumn ===
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn ===
column.id
? 'ring-2 ring-primary bg-primary/5'
? 'ring-2 ring-primary'
: ''}"
ondragover={(e) => handleDragOver(e, column.id)}
ondragleave={handleDragLeave}
ondragover={(e) => handleColumnDragOver(e, column.id)}
ondragleave={handleColumnDragLeave}
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 Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px]">
<div class="flex items-center gap-2 flex-1 min-w-0">
<h3 class="font-heading text-h4 text-white truncate">
{column.name}
</h3>
<div
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
>
{column.cards.length}
<span class="font-heading text-h6 text-white"
>{column.cards.length}</span
>
</div>
</div>
<button
type="button"
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0"
onclick={() => onDeleteColumn?.(column.id)}
aria-label="Column options"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</h3>
<div class="flex items-center gap-1">
{#if column.color}
</button>
</div>
<!-- Cards -->
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
{#each column.cards as card, cardIndex}
<!-- Drop indicator before card -->
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id}
<div
class="w-3 h-3 rounded-full"
style="background-color: {column.color}"
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
></div>
{/if}
{#if canEdit}
<button
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
onclick={() => onDeleteColumn?.(column.id)}
title="Delete column"
>
<svg
class="w-3.5 h-3.5"
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>
<div class="flex-1 overflow-y-auto space-y-2">
{#each column.cards as card}
<div
class="group bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all relative"
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"
class="mb-2"
ondragover={(e) =>
handleCardDragOver(e, column.id, cardIndex)}
>
{#if canEdit}
<button
class="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
onclick={(e) => handleDeleteCard(e, card.id)}
title="Delete task"
>
<svg
class="w-3.5 h-3.5"
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}
{#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 pr-6">{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 || (card as any).checklist_total > 0 || (card as any).assignee_id}
<div class="mt-2 flex items-center gap-2 flex-wrap">
{#if card.due_date}
<Badge
size="sm"
variant={getDueDateColor(card.due_date)}
>
{formatDueDate(card.due_date)}
</Badge>
{/if}
{#if (card as any).checklist_total > 0}
<span
class="text-xs flex items-center gap-1 {(
card as any
).checklist_done ===
(card as any).checklist_total
? 'text-success'
: 'text-light/50'}"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline
points="9,11 12,14 22,4"
/>
<path
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
/>
</svg>
{(card as any).checklist_done}/{(
card as any
).checklist_total}
</span>
{/if}
{#if (card as any).assignee_id}
<div
class="w-5 h-5 rounded-full bg-primary/30 flex items-center justify-center text-[10px] text-primary ml-auto"
title="Assigned"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
/>
<circle cx="12" cy="7" r="4" />
</svg>
</div>
{/if}
</div>
{/if}
<KanbanCardComponent
{card}
isDragging={draggedCard?.id === card.id}
draggable={canEdit}
ondragstart={(e) => handleDragStart(e, card)}
onclick={() => onCardClick?.(card)}
ondelete={canEdit
? (id) => onDeleteCard?.(id)
: undefined}
/>
</div>
{/each}
<!-- Drop indicator at end of column -->
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length}
<div
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
></div>
{/if}
</div>
<!-- Add Card Button (secondary style) -->
{#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"
type="button"
class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors"
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}
<!-- Add Column Button -->
{#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"
type="button"
class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors"
onclick={() => onAddColumn?.()}
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
add
</span>
Add column
</button>
{/if}
</div>
<style>
.scrollbar-visible {
.kanban-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(229, 230, 240, 0.3) transparent;
}
.scrollbar-visible::-webkit-scrollbar {
.kanban-scroll::-webkit-scrollbar {
height: 8px;
}
.scrollbar-visible::-webkit-scrollbar-track {
background: rgba(229, 230, 240, 0.1);
.kanban-scroll::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb {
.kanban-scroll::-webkit-scrollbar-thumb {
background: rgba(229, 230, 240, 0.3);
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
.kanban-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(229, 230, 240, 0.5);
}
</style>

View File

@@ -1,17 +1,24 @@
<script lang="ts">
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
import { Badge } from "$lib/components/ui";
import { Avatar } 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 Tag {
id: string;
name: string;
color: string;
}
interface Props {
card: ExtendedCard;
card: KanbanCardType & {
tags?: Tag[];
checklist_done?: number;
checklist_total?: number;
assignee_name?: string | null;
assignee_avatar?: string | null;
};
isDragging?: boolean;
onclick?: () => void;
ondelete?: (cardId: string) => void;
draggable?: boolean;
ondragstart?: (e: DragEvent) => void;
}
@@ -20,114 +27,125 @@
card,
isDragging = false,
onclick,
ondelete,
draggable = true,
ondragstart,
}: Props = $props();
function handleDelete(e: MouseEvent) {
e.stopPropagation();
if (confirm("Are you sure you want to delete this card?")) {
ondelete?.(card.id);
}
}
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();
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
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";
}
}
const hasFooter = $derived(
!!card.due_date ||
(card.checklist_total ?? 0) > 0 ||
!!card.assignee_id,
);
</script>
<div
class="bg-night rounded-[16px] p-3 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group"
<button
type="button"
class="bg-night rounded-[16px] p-2 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group w-full text-left overflow-clip flex flex-col gap-2 relative"
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>
<!-- Delete button (top-right, visible on hover) -->
{#if ondelete}
<button
type="button"
class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
<span
class="material-symbols-rounded text-light/40 hover:text-error"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
delete
</span>
</button>
{/if}
<!-- Tags / Chips -->
{#if card.tags && card.tags.length > 0}
<div class="flex gap-[10px] items-start flex-wrap">
{#each card.tags as tag}
<span
class="rounded-[4px] px-1 py-[4px] font-body font-bold text-[14px] text-night leading-none overflow-clip"
style="background-color: {tag.color || '#00A3E0'}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
<!-- Title -->
<p class="text-sm font-medium text-light">{card.title}</p>
<p class="font-body text-body text-white w-full leading-none">
{card.title}
</p>
<!-- Description -->
{#if card.description}
<p class="text-xs text-light/50 mt-1 line-clamp-2">
{card.description}
</p>
{/if}
<!-- Bottom row: details + avatar -->
{#if hasFooter}
<div class="flex items-center justify-between w-full">
<div class="flex gap-1 items-center">
<!-- Due date -->
{#if card.due_date}
<div class="flex items-center">
<span
class="material-symbols-rounded text-light p-1"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
calendar_today
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
{formatDueDate(card.due_date)}
</span>
</div>
{/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
<!-- Checklist -->
{#if (card.checklist_total ?? 0) > 0}
<div class="flex items-center">
<span
class="material-symbols-rounded text-light p-1"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
check_box
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
{card.checklist_done ?? 0}/{card.checklist_total}
</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Assignee avatar -->
{#if card.assignee_id}
<Avatar
name={card.assignee_name || "?"}
src={card.assignee_avatar}
size="sm"
/>
{/if}
</div>
{/if}
</button>

View File

@@ -1,3 +1,6 @@
export { default as KanbanBoard } from './KanbanBoard.svelte';
export { default as CardDetailModal } from './CardDetailModal.svelte';
export { default as KanbanCard } from './KanbanCard.svelte';
export { default as CardChecklist } from './CardChecklist.svelte';
export { default as CardComments } from './CardComments.svelte';
export { default as CardMetadata } from './CardMetadata.svelte';

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { Button, Input, Avatar } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/toast.svelte";
import { invalidateAll } from "$app/navigation";
interface Props {
supabase: SupabaseClient<Database>;
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
isOwner: boolean;
onLeave: () => void;
onDelete: () => void;
}
let { supabase, org, isOwner, onLeave, onDelete }: Props = $props();
let orgName = $state(org.name);
let orgSlug = $state(org.slug);
let avatarUrl = $state(org.avatar_url ?? null);
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
$effect(() => {
orgName = org.name;
orgSlug = org.slug;
avatarUrl = org.avatar_url ?? null;
});
async function handleAvatarUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate file
if (!file.type.startsWith("image/")) {
toasts.error("Please select an image file.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toasts.error("Image must be under 2MB.");
return;
}
isUploading = true;
try {
const ext = file.name.split(".").pop() || "png";
const path = `org-avatars/${org.id}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, file, { upsert: true });
if (uploadError) {
toasts.error("Failed to upload avatar.");
return;
}
const { data: urlData } = supabase.storage
.from("avatars")
.getPublicUrl(path);
const publicUrl = `${urlData.publicUrl}?t=${Date.now()}`;
const { error: dbError } = await supabase
.from("organizations")
.update({ avatar_url: publicUrl })
.eq("id", org.id);
if (dbError) {
toasts.error("Failed to save avatar URL.");
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
} catch (err) {
toasts.error("Avatar upload failed.");
} finally {
isUploading = false;
input.value = "";
}
}
async function removeAvatar() {
isSaving = true;
const { error } = await supabase
.from("organizations")
.update({ avatar_url: null })
.eq("id", org.id);
if (error) {
toasts.error("Failed to remove avatar.");
} else {
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
}
isSaving = false;
}
async function saveGeneralSettings() {
isSaving = true;
const { error } = await supabase
.from("organizations")
.update({ name: orgName, slug: orgSlug })
.eq("id", org.id);
if (error) {
toasts.error("Failed to save settings.");
} else if (orgSlug !== org.slug) {
window.location.href = `/${orgSlug}/settings`;
} else {
toasts.success("Settings saved.");
}
isSaving = false;
}
</script>
<div class="flex flex-col gap-8">
<!-- Organization Details -->
<h2 class="font-heading text-h2 text-white">Organization details</h2>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<!-- Avatar Upload -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light">Avatar</span>
<div class="flex items-center gap-4">
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
<div class="flex gap-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={avatarInput}
onchange={handleAvatarUpload}
/>
<Button
variant="secondary"
size="sm"
onclick={() => avatarInput?.click()}
loading={isUploading}
>
Upload
</Button>
{#if avatarUrl}
<Button
variant="tertiary"
size="sm"
onclick={removeAvatar}
>
Remove
</Button>
{/if}
</div>
</div>
</div>
<Input
label="Name"
bind:value={orgName}
placeholder="Organization name"
/>
<Input
label="URL slug (yoursite.com/...)"
bind:value={orgSlug}
placeholder="my-org"
/>
<div>
<Button onclick={saveGeneralSettings} loading={isSaving}
>Save Changes</Button
>
</div>
</div>
<!-- Danger Zone -->
{#if isOwner}
<div class="flex flex-col gap-4">
<h4 class="font-heading text-h4 text-white">Danger Zone</h4>
<p class="font-body text-body text-white">
Permanently delete this organization and all its data.
</p>
<div>
<Button variant="danger" onclick={onDelete}
>Delete Organization</Button
>
</div>
</div>
{/if}
<!-- Leave Organization (non-owners) -->
{#if !isOwner}
<div class="flex flex-col gap-4">
<h4 class="font-heading text-h4 text-white">
Leave Organization
</h4>
<p class="font-body text-body text-white">
Leave this organization. You will need to be re-invited to
rejoin.
</p>
<div>
<Button variant="secondary" onclick={onLeave}
>Leave {org.name}</Button
>
</div>
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
interface Member {
id: string;
user_id: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
value: string | null;
members: Member[];
label?: string;
onchange: (userId: string | null) => void;
}
let { value, members, label, onchange }: Props = $props();
let isOpen = $state(false);
function getAssignee(id: string | null) {
if (!id) return null;
return members.find((m) => m.user_id === id);
}
const assignee = $derived(getAssignee(value));
function select(userId: string | null) {
onchange(userId);
isOpen = false;
}
</script>
<div class="flex flex-col gap-3 w-full">
{#if label}
<span class="px-3 font-bold font-body text-body text-white">
{label}
</span>
{/if}
<div class="relative">
<button
type="button"
class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
font-medium font-input text-body
focus:outline-none focus:ring-2 focus:ring-primary
transition-colors text-left flex items-center gap-3"
onclick={() => (isOpen = !isOpen)}
>
{#if assignee}
<Avatar
name={assignee.profiles.full_name ||
assignee.profiles.email}
size="sm"
/>
<span class="truncate">
{assignee.profiles.full_name || assignee.profiles.email}
</span>
{:else}
<Avatar name="?" size="sm" />
<span class="text-white/40">Unassigned</span>
{/if}
</button>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40"
onclick={() => (isOpen = false)}
></div>
<div
class="absolute top-full left-0 right-0 mt-2 bg-night border border-light/10 rounded-2xl shadow-xl z-50 max-h-48 overflow-y-auto py-1"
>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-body-md text-white/60 hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => select(null)}
>
<Avatar name="?" size="sm" />
Unassigned
</button>
{#each members as member}
<button
type="button"
class="w-full px-4 py-2.5 text-left text-body-md hover:bg-dark transition-colors flex items-center gap-3
{value === member.user_id ? 'bg-primary/10 text-primary' : 'text-white'}"
onclick={() => select(member.user_id)}
>
<Avatar
name={member.profiles.full_name ||
member.profiles.email}
size="sm"
/>
<span class="truncate">
{member.profiles.full_name || member.profiles.email}
</span>
</button>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -1,92 +1,35 @@
<script lang="ts">
interface Props {
name: string;
src?: string | null;
name?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
status?: 'online' | 'offline' | 'away' | 'busy' | null;
size?: "sm" | "md" | "lg" | "xl";
}
let { src = null, name = '?', size = 'md', status = null }: Props = $props();
let { name, src = null, size = "md" }: 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 initial = $derived(name ? name[0].toUpperCase() : "?");
const sizes = {
sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" },
md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" },
lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" },
xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" },
};
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">
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
<div
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[
size
]} {!src ? getColorFromName(name) : 'bg-surface'}"
class="{sizes[size].box} {sizes[size]
.radius} bg-primary flex items-center justify-center shrink-0"
>
{#if src}
<img {src} alt={name} class="w-full h-full object-cover" />
{:else}
{getInitials(name)}
{/if}
<span class="font-heading {sizes[size].text} text-night leading-none">
{initial}
</span>
</div>
{#if status}
<div
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[
status
]}"
></div>
{/if}
</div>
{/if}

View File

@@ -1,30 +1,40 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
interface Props {
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md' | 'lg';
variant?:
| "default"
| "primary"
| "success"
| "warning"
| "error"
| "info";
size?: "sm" | "md" | "lg";
children: Snippet;
}
let { variant = 'default', size = 'md', children }: Props = $props();
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'
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'
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]}">
<span
class="inline-flex items-center font-medium rounded-full {variantClasses[
variant
]} {sizeClasses[size]}"
>
{@render children()}
</span>

View File

@@ -2,14 +2,16 @@
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
variant?: "primary" | "secondary" | "tertiary" | "danger" | "success";
size?: "sm" | "md" | "lg";
disabled?: boolean;
loading?: boolean;
type?: "button" | "submit" | "reset";
fullWidth?: boolean;
icon?: string;
type?: "button" | "submit" | "reset";
onclick?: (e: MouseEvent) => void;
children: Snippet;
children?: Snippet;
class?: string;
}
let {
@@ -17,59 +19,100 @@
size = "md",
disabled = false,
loading = false,
type = "button",
fullWidth = false,
icon,
type = "button",
onclick,
children,
class: className,
}: Props = $props();
// Figma-matched base styles
const baseClasses =
"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]";
"inline-flex items-center justify-center gap-2 font-heading rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
// Figma-matched variant styles
const variantClasses = {
primary:
"bg-primary text-night hover:brightness-110 active:brightness-90",
"btn-primary bg-primary text-night hover:btn-primary-hover active:btn-primary-active",
secondary:
"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",
"bg-transparent text-primary border-solid border-primary hover:bg-primary/10 active:bg-primary/20",
tertiary:
"bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
danger: "btn-primary bg-error text-white hover:btn-primary-hover active:btn-primary-active",
success:
"bg-success text-night hover:brightness-110 active:brightness-90",
"btn-primary bg-success text-night hover:btn-primary-hover active:btn-primary-active",
};
// Figma-matched size styles (px values from Figma)
const sizeClasses = {
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]",
sm: "min-w-[36px] p-[10px] text-btn-sm",
md: "min-w-[48px] p-[12px] text-btn-md",
lg: "min-w-[56px] p-[16px] text-btn-lg",
};
const borderClasses = {
sm: "border-2",
md: "border-3",
lg: "border-4",
};
const secondaryBorder = $derived(
variant === "secondary" ? borderClasses[size] : "",
);
const iconSize = $derived(size === "sm" ? 16 : size === "lg" ? 20 : 18);
</script>
<button
{type}
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}"
class="{baseClasses} {variantClasses[variant]} {sizeClasses[
size
]} {secondaryBorder} {className ?? ''}"
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>
<span
class="material-symbols-rounded animate-spin"
style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};"
>
progress_activity
</span>
{:else if icon}
<span
class="material-symbols-rounded"
style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};"
>
{icon}
</span>
{/if}
{#if children}
{@render children()}
{/if}
{@render children()}
</button>
<style>
.btn-primary:hover:not(:disabled) {
background-image: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
);
}
.btn-primary-hover:not(:disabled) {
background-image: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
);
}
.btn-primary:active:not(:disabled) {
background-image: linear-gradient(
rgba(14, 15, 25, 0.2),
rgba(14, 15, 25, 0.2)
);
}
.btn-primary-active:not(:disabled) {
background-image: linear-gradient(
rgba(14, 15, 25, 0.2),
rgba(14, 15, 25, 0.2)
);
}
</style>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
day?: number | string;
isHeader?: boolean;
isPast?: boolean;
events?: Snippet;
}
let { day, isHeader = false, isPast = false, events }: Props = $props();
</script>
{#if isHeader}
<div
class="flex flex-col items-center justify-center px-2 pt-2 pb-4 w-full"
>
<span class="font-heading text-h4 text-white text-center truncate">
{day}
</span>
</div>
{:else}
<div
class="flex flex-col items-start gap-2 bg-night px-4 py-5 min-h-[82px] w-full {isPast
? 'opacity-50'
: ''}"
>
<span class="font-body text-body text-white truncate w-full">
{day}
</span>
{#if events}
{@render events()}
{/if}
</div>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "success" | "warning" | "error" | "default";
children: Snippet;
}
let { variant = "primary", children }: Props = $props();
const variantClasses = {
primary: "bg-primary text-background",
success: "bg-success text-background",
warning: "bg-warning text-background",
error: "bg-error text-background",
default: "bg-dark text-light",
};
</script>
<div
class="inline-flex items-center justify-center px-1 py-1 rounded-[4px] overflow-hidden font-bold font-body text-body-md {variantClasses[
variant
]}"
>
{@render children()}
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Button from "./Button.svelte";
interface Props {
title: string;
actionLabel?: string;
onAction?: () => void;
onMore?: () => void;
children?: Snippet;
}
let { title, actionLabel, onAction, onMore, children }: Props = $props();
</script>
<div class="flex flex-wrap items-center gap-2 p-1 rounded-[32px] w-full">
<div class="flex-1 min-w-0">
<h1 class="font-heading text-h1 text-white truncate">{title}</h1>
</div>
{#if children}
{@render children()}
{/if}
{#if actionLabel && onAction}
<Button variant="primary" onclick={onAction}>
{actionLabel}
</Button>
{/if}
{#if onMore}
<button
type="button"
class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors"
onclick={onMore}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
{/if}
</div>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
trigger: Snippet;
children: Snippet;
align?: "left" | "right";
width?: "auto" | "sm" | "md" | "lg";
}
let { trigger, children, align = "left", width = "auto" }: Props = $props();
let isOpen = $state(false);
const alignClasses = {
left: "left-0",
right: "right-0",
};
const widthClasses = {
auto: "min-w-[10rem]",
sm: "w-48",
md: "w-56",
lg: "w-64",
};
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest(".dropdown-container")) {
isOpen = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") isOpen = false;
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="relative dropdown-container">
<button
type="button"
class="w-full text-left"
onclick={() => (isOpen = !isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
>
{@render trigger()}
</button>
{#if isOpen}
<div
class="
absolute z-50 mt-2 py-1 bg-surface border border-light/10 rounded-xl shadow-xl
animate-in fade-in slide-in-from-top-2 duration-150
{alignClasses[align]}
{widthClasses[width]}
"
>
{@render children()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
onclick?: () => void;
icon?: Snippet;
danger?: boolean;
disabled?: boolean;
}
let { children, onclick, icon, danger = false, disabled = false }: Props = $props();
</script>
<button
type="button"
{onclick}
{disabled}
class="
w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
{danger ? 'text-error hover:bg-error/10' : 'text-light hover:bg-light/5'}
"
>
{#if icon}
<span class="w-4 h-4 shrink-0 opacity-60">
{@render icon()}
</span>
{/if}
<span class="flex-1">{@render children()}</span>
</button>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
icon?: Snippet;
title: string;
description?: string;
action?: Snippet;
}
let { icon, title, description, action }: Props = $props();
</script>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
{#if icon}
<div class="w-12 h-12 sm:w-16 sm:h-16 text-light/30 mb-4">
{@render icon()}
</div>
{/if}
<h3 class="text-base sm:text-lg font-medium text-light mb-1">{title}</h3>
{#if description}
<p class="text-sm text-light/50 max-w-sm mb-4">{description}</p>
{/if}
{#if action}
<div class="mt-2">
{@render action()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
name: string;
size?: number;
class?: string;
}
let { name, size = 24, class: className = "" }: Props = $props();
</script>
<span
class="material-symbols-rounded {className}"
style="font-size: {size}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {size};"
>
{name}
</span>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
onclick?: () => void;
variant?: 'ghost' | 'subtle' | 'solid';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
title?: string;
class?: string;
}
let {
children,
onclick,
variant = 'ghost',
size = 'md',
disabled = false,
title,
class: className = '',
}: Props = $props();
const variantClasses = {
ghost: 'hover:bg-light/10 text-light/60 hover:text-light',
subtle: 'bg-light/5 hover:bg-light/10 text-light/60 hover:text-light',
solid: 'bg-primary/20 hover:bg-primary/30 text-primary',
};
const sizeClasses = {
sm: 'w-7 h-7',
md: 'w-9 h-9',
lg: 'w-11 h-11',
};
const iconSizeClasses = {
sm: '[&>svg]:w-4 [&>svg]:h-4',
md: '[&>svg]:w-5 [&>svg]:h-5',
lg: '[&>svg]:w-6 [&>svg]:h-6',
};
</script>
<button
type="button"
{onclick}
{disabled}
{title}
aria-label={title}
class="
inline-flex items-center justify-center rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
{variantClasses[variant]}
{sizeClasses[size]}
{iconSizeClasses[size]}
{className}
"
>
{@render children()}
</button>

View File

@@ -1,6 +1,15 @@
<script lang="ts">
interface Props {
type?: "text" | "password" | "email" | "url" | "search" | "number";
type?:
| "text"
| "password"
| "email"
| "url"
| "search"
| "number"
| "tel"
| "date"
| "datetime-local";
value?: string;
placeholder?: string;
label?: string;
@@ -9,7 +18,9 @@
disabled?: boolean;
required?: boolean;
autocomplete?: AutoFill;
icon?: string;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onkeydown?: (e: KeyboardEvent) => void;
}
@@ -23,7 +34,9 @@
disabled = false,
required = false,
autocomplete,
icon,
oninput,
onchange,
onkeydown,
}: Props = $props();
@@ -33,67 +46,72 @@
const inputType = $derived(isPassword && showPassword ? "text" : type);
</script>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 w-full">
{#if label}
<label for={inputId} class="px-3 font-heading text-xl text-white">
<label
for={inputId}
class="px-3 font-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
{/if}
<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)}
<div class="flex items-center gap-3 w-full">
{#if icon}
<div
class="w-8 h-8 rounded-full bg-light flex items-center justify-center shrink-0"
>
{#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>
<span
class="material-symbols-rounded text-background"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{icon}
</span>
</div>
{/if}
<div class="relative flex-1">
<input
id={inputId}
type={inputType}
bind:value
{placeholder}
{disabled}
{required}
{autocomplete}
{oninput}
{onchange}
{onkeydown}
class="
w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
font-medium font-input text-body
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}
class:pr-12={isPassword}
/>
{#if isPassword}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-colors"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword
? "Hide password"
: "Show password"}
>
<span
class="material-symbols-rounded"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{showPassword ? "visibility_off" : "visibility"}
</span>
</button>
{/if}
</div>
</div>
{#if error}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Button from "./Button.svelte";
interface Props {
title: string;
count?: number;
onAddCard?: () => void;
onMore?: () => void;
children?: Snippet;
}
let { title, count = 0, onAddCard, onMore, children }: Props = $props();
</script>
<div
class="bg-background flex flex-col gap-4 items-start overflow-hidden px-4 py-5 rounded-[32px] w-64 h-[512px]"
>
<!-- Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px] w-full">
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="font-heading text-h4 text-white truncate">{title}</span
>
<div
class="bg-dark flex items-center justify-center p-1 rounded-lg shrink-0"
>
<span class="font-heading text-h6 text-white">{count}</span>
</div>
</div>
{#if onMore}
<button
type="button"
class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors"
onclick={onMore}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
{/if}
</div>
<!-- Cards container -->
<div
class="flex-1 flex flex-col gap-2 items-start overflow-y-auto w-full min-h-0"
>
{#if children}
{@render children()}
{/if}
</div>
<!-- Add button -->
{#if onAddCard}
<Button variant="secondary" fullWidth onclick={onAddCard}>
Add card
</Button>
{/if}
</div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
variant?: "default" | "hover" | "active";
icon?: string;
size?: "sm" | "md";
onclick?: () => void;
children: Snippet;
}
let {
variant = "default",
icon,
size = "md",
onclick,
children,
}: Props = $props();
const baseClasses =
"flex items-center gap-2 overflow-hidden rounded-[32px] transition-colors cursor-pointer";
const variantClasses = {
default: "bg-night hover:bg-dark",
hover: "bg-dark",
active: "bg-primary text-background",
};
const sizeClasses = {
sm: "h-10 pl-1 pr-2 py-1",
md: "h-10 pl-1 pr-2 py-1",
};
</script>
<button
type="button"
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]} w-full"
{onclick}
>
{#if icon}
<div class="w-8 h-8 flex items-center justify-center p-1 shrink-0">
<span
class="material-symbols-rounded {variant === 'active'
? 'text-background'
: 'text-light'}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{icon}
</span>
</div>
{/if}
<span
class="flex-1 text-left font-body text-body truncate {variant ===
'active'
? 'text-background'
: 'text-white'}"
>
{@render children()}
</span>
</button>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
size?: "sm" | "md";
}
let { size = "md" }: Props = $props();
const sizeClasses = {
sm: "w-10 h-10",
md: "w-12 h-12",
};
</script>
<div class="flex items-center justify-center {sizeClasses[size]}">
<svg
viewBox="0 0 38 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-auto"
>
<!-- Root logo SVG paths matching Figma -->
<path
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
fill="#00A3E0"
fill-opacity="0.2"
/>
<!-- Left eye -->
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Right eye -->
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Mouth/smile -->
<path
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
stroke="#00A3E0"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>

View File

@@ -1,25 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
isOpen: boolean;
onClose: () => void;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
size?: "sm" | "md" | "lg" | "xl";
children: Snippet;
}
let { isOpen, onClose, title, size = 'md', children }: Props = $props();
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'
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (e.key === "Escape") {
onClose();
}
}
@@ -39,23 +41,40 @@
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
aria-labelledby={title ? "modal-title" : undefined}
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<div
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl"
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[
size
]} shadow-xl"
onclick={(e) => e.stopPropagation()}
role="document"
transition:fly={{ y: 10, duration: 200, easing: cubicOut }}
>
{#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>
<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">
<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>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
interface Props {
name: string;
role?: string;
size?: "sm" | "md";
isHover?: boolean;
}
let { name, role, size = "md", isHover = false }: Props = $props();
</script>
<div
class="flex items-center gap-2 p-1 rounded-[32px] w-full transition-colors {isHover
? 'bg-dark'
: 'bg-night'}"
>
<Avatar {name} size={size === "sm" ? "sm" : "md"} />
{#if size !== "sm"}
<div class="flex-1 flex flex-col min-w-0">
<span class="font-heading text-h3 text-white truncate">{name}</span>
{#if role}
<span class="font-body text-body-sm text-white truncate"
>{role}</span
>
{/if}
</div>
{/if}
</div>

View File

@@ -10,8 +10,10 @@
label?: string;
placeholder?: string;
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
onchange?: (e: Event) => void;
}
let {
@@ -20,18 +22,22 @@
label,
placeholder = "Select...",
error,
hint,
disabled = false,
required = false,
onchange,
}: Props = $props();
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`;
</script>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-3 w-full">
{#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-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
{/if}
@@ -40,21 +46,27 @@
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}
{onchange}
class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
font-medium font-input text-body
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors appearance-none cursor-pointer"
class:ring-1={error}
class:ring-error={error}
>
<option value="" disabled>{placeholder}</option>
{#if placeholder}
<option value="" disabled>{placeholder}</option>
{/if}
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{#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-white/50 px-3">{hint}</p>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
interface Props {
class?: string;
variant?: 'text' | 'circular' | 'rectangular' | 'card';
width?: string;
height?: string;
lines?: number;
}
let {
class: className = '',
variant = 'text',
width,
height,
lines = 1,
}: Props = $props();
const variantClasses: Record<string, string> = {
text: 'h-4 rounded',
circular: 'rounded-full',
rectangular: 'rounded-lg',
card: 'rounded-2xl',
};
const defaultSizes: Record<string, { w: string; h: string }> = {
text: { w: '100%', h: '1rem' },
circular: { w: '2.5rem', h: '2.5rem' },
rectangular: { w: '100%', h: '4rem' },
card: { w: '100%', h: '8rem' },
};
const finalWidth = width || defaultSizes[variant].w;
const finalHeight = height || defaultSizes[variant].h;
</script>
{#if variant === 'text' && lines > 1}
<div class="space-y-2 {className}">
{#each Array(lines) as _, i}
<div
class="skeleton {variantClasses[variant]}"
style="width: {i === lines - 1 ? '75%' : finalWidth}; height: {finalHeight}"
></div>
{/each}
</div>
{:else}
<div
class="skeleton {variantClasses[variant]} {className}"
style="width: {finalWidth}; height: {finalHeight}"
></div>
{/if}
<style>
.skeleton {
background: linear-gradient(
90deg,
rgb(var(--color-light) / 0.06) 0%,
rgb(var(--color-light) / 0.12) 50%,
rgb(var(--color-light) / 0.06) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -8,36 +8,38 @@
disabled?: boolean;
required?: boolean;
rows?: number;
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
resize?: "none" | "vertical" | "horizontal" | "both";
}
let {
value = $bindable(''),
placeholder = '',
value = $bindable(""),
placeholder = "",
label,
error,
hint,
disabled = false,
required = false,
rows = 3,
resize = 'vertical'
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'
none: "resize-none",
vertical: "resize-y",
horizontal: "resize-x",
both: "resize",
};
</script>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-3 w-full">
{#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-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
{/if}
@@ -48,19 +50,19 @@
{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}
class="w-full p-3 bg-background text-white rounded-2xl min-w-[192px]
font-medium font-input text-body
placeholder:text-white/40
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors {resizeClasses[resize]}"
class:ring-1={error}
class:ring-error={error}
></textarea>
{#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>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { toasts } from '$lib/stores/toast';
import Toast from './Toast.svelte';
import { toasts } from "$lib/stores/toast.svelte";
import Toast from "./Toast.svelte";
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">

View File

@@ -10,3 +10,17 @@ export { default as Spinner } from './Spinner.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Toast } from './Toast.svelte';
export { default as ToastContainer } from './ToastContainer.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as EmptyState } from './EmptyState.svelte';
export { default as IconButton } from './IconButton.svelte';
export { default as Dropdown } from './Dropdown.svelte';
export { default as DropdownItem } from './DropdownItem.svelte';
export { default as Chip } from './Chip.svelte';
export { default as ListItem } from './ListItem.svelte';
export { default as CalendarDay } from './CalendarDay.svelte';
export { default as OrgHeader } from './OrgHeader.svelte';
export { default as KanbanColumn } from './KanbanColumn.svelte';
export { default as Logo } from './Logo.svelte';
export { default as ContentHeader } from './ContentHeader.svelte';
export { default as Icon } from './Icon.svelte';
export { default as AssigneePicker } from './AssigneePicker.svelte';