Mega push vol 4
This commit is contained in:
@@ -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}
|
||||
|
||||
105
src/lib/components/documents/DocumentViewer.svelte
Normal file
105
src/lib/components/documents/DocumentViewer.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
874
src/lib/components/documents/FileBrowser.svelte
Normal file
874
src/lib/components/documents/FileBrowser.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
170
src/lib/components/kanban/CardChecklist.svelte
Normal file
170
src/lib/components/kanban/CardChecklist.svelte
Normal 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>
|
||||
159
src/lib/components/kanban/CardComments.svelte
Normal file
159
src/lib/components/kanban/CardComments.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
90
src/lib/components/kanban/CardMetadata.svelte
Normal file
90
src/lib/components/kanban/CardMetadata.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
216
src/lib/components/settings/SettingsGeneral.svelte
Normal file
216
src/lib/components/settings/SettingsGeneral.svelte
Normal 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>
|
||||
1
src/lib/components/settings/index.ts
Normal file
1
src/lib/components/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SettingsGeneral } from './SettingsGeneral.svelte';
|
||||
108
src/lib/components/ui/AssigneePicker.svelte
Normal file
108
src/lib/components/ui/AssigneePicker.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
src/lib/components/ui/CalendarDay.svelte
Normal file
35
src/lib/components/ui/CalendarDay.svelte
Normal 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}
|
||||
26
src/lib/components/ui/Chip.svelte
Normal file
26
src/lib/components/ui/Chip.svelte
Normal 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>
|
||||
42
src/lib/components/ui/ContentHeader.svelte
Normal file
42
src/lib/components/ui/ContentHeader.svelte
Normal 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>
|
||||
64
src/lib/components/ui/Dropdown.svelte
Normal file
64
src/lib/components/ui/Dropdown.svelte
Normal 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>
|
||||
31
src/lib/components/ui/DropdownItem.svelte
Normal file
31
src/lib/components/ui/DropdownItem.svelte
Normal 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>
|
||||
29
src/lib/components/ui/EmptyState.svelte
Normal file
29
src/lib/components/ui/EmptyState.svelte
Normal 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>
|
||||
16
src/lib/components/ui/Icon.svelte
Normal file
16
src/lib/components/ui/Icon.svelte
Normal 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>
|
||||
59
src/lib/components/ui/IconButton.svelte
Normal file
59
src/lib/components/ui/IconButton.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
61
src/lib/components/ui/KanbanColumn.svelte
Normal file
61
src/lib/components/ui/KanbanColumn.svelte
Normal 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>
|
||||
60
src/lib/components/ui/ListItem.svelte
Normal file
60
src/lib/components/ui/ListItem.svelte
Normal 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>
|
||||
39
src/lib/components/ui/Logo.svelte
Normal file
39
src/lib/components/ui/Logo.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
30
src/lib/components/ui/OrgHeader.svelte
Normal file
30
src/lib/components/ui/OrgHeader.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
72
src/lib/components/ui/Skeleton.svelte
Normal file
72
src/lib/components/ui/Skeleton.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user