Mega push vol2
This commit is contained in:
@@ -1,28 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CalendarEvent } from '$lib/supabase/types';
|
import type { CalendarEvent } from "$lib/supabase/types";
|
||||||
import { getMonthDays, isSameDay } from '$lib/api/calendar';
|
import { getMonthDays, isSameDay } from "$lib/api/calendar";
|
||||||
|
|
||||||
|
type ViewType = "month" | "week" | "day";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
onDateClick?: (date: Date) => void;
|
onDateClick?: (date: Date) => void;
|
||||||
onEventClick?: (event: CalendarEvent) => void;
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
initialView?: ViewType;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { events, onDateClick, onEventClick }: Props = $props();
|
let {
|
||||||
|
events,
|
||||||
|
onDateClick,
|
||||||
|
onEventClick,
|
||||||
|
initialView = "month",
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let currentDate = $state(new Date());
|
let currentDate = $state(new Date());
|
||||||
|
let currentView = $state<ViewType>(initialView);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
||||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
const days = $derived(getMonthDays(currentDate.getFullYear(), currentDate.getMonth()));
|
const days = $derived(
|
||||||
|
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()),
|
||||||
|
);
|
||||||
|
|
||||||
function prevMonth() {
|
function prevMonth() {
|
||||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
|
currentDate = new Date(
|
||||||
|
currentDate.getFullYear(),
|
||||||
|
currentDate.getMonth() - 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextMonth() {
|
function nextMonth() {
|
||||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
currentDate = new Date(
|
||||||
|
currentDate.getFullYear(),
|
||||||
|
currentDate.getMonth() + 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToToday() {
|
function goToToday() {
|
||||||
@@ -41,14 +60,109 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const monthYear = $derived(
|
const monthYear = $derived(
|
||||||
currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
currentDate.toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get week days for week view
|
||||||
|
function getWeekDays(date: Date): Date[] {
|
||||||
|
const startOfWeek = new Date(date);
|
||||||
|
startOfWeek.setDate(date.getDate() - date.getDay());
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(startOfWeek);
|
||||||
|
d.setDate(startOfWeek.getDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDates = $derived(getWeekDays(currentDate));
|
||||||
|
|
||||||
|
// Navigation functions for different views
|
||||||
|
function prev() {
|
||||||
|
if (currentView === "month") {
|
||||||
|
currentDate = new Date(
|
||||||
|
currentDate.getFullYear(),
|
||||||
|
currentDate.getMonth() - 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else if (currentView === "week") {
|
||||||
|
currentDate = new Date(
|
||||||
|
currentDate.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
currentDate = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (currentView === "month") {
|
||||||
|
currentDate = new Date(
|
||||||
|
currentDate.getFullYear(),
|
||||||
|
currentDate.getMonth() + 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else if (currentView === "week") {
|
||||||
|
currentDate = new Date(
|
||||||
|
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
currentDate = new Date(currentDate.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerTitle = $derived(() => {
|
||||||
|
if (currentView === "day") {
|
||||||
|
return currentDate.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} else if (currentView === "week") {
|
||||||
|
const start = weekDates[0];
|
||||||
|
const end = weekDates[6];
|
||||||
|
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
||||||
|
}
|
||||||
|
return monthYear;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-surface rounded-xl p-4">
|
<div class="bg-surface rounded-xl p-4">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold text-light">{monthYear}</h2>
|
<h2 class="text-xl font-semibold text-light">{headerTitle()}</h2>
|
||||||
<div class="flex items-center gap-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
|
<button
|
||||||
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||||
onclick={goToToday}
|
onclick={goToToday}
|
||||||
@@ -57,28 +171,46 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||||
onclick={prevMonth}
|
onclick={prev}
|
||||||
aria-label="Previous month"
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<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">
|
|
||||||
<path d="m15 18-6-6 6-6" />
|
<path d="m15 18-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||||
onclick={nextMonth}
|
onclick={next}
|
||||||
aria-label="Next month"
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<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">
|
|
||||||
<path d="m9 18 6-6-6-6" />
|
<path d="m9 18 6-6-6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden">
|
<!-- Month View -->
|
||||||
|
{#if currentView === "month"}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
{#each weekDays as day}
|
{#each weekDays as day}
|
||||||
<div class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50">
|
<div
|
||||||
|
class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50"
|
||||||
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -105,7 +237,9 @@
|
|||||||
{#each dayEvents.slice(0, 3) as event}
|
{#each dayEvents.slice(0, 3) as event}
|
||||||
<button
|
<button
|
||||||
class="w-full text-xs px-1 py-0.5 rounded truncate text-left"
|
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'}"
|
style="background-color: {event.color ??
|
||||||
|
'#6366f1'}20; color: {event.color ??
|
||||||
|
'#6366f1'}"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEventClick?.(event);
|
onEventClick?.(event);
|
||||||
@@ -115,10 +249,105 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if dayEvents.length > 3}
|
{#if dayEvents.length > 3}
|
||||||
<p class="text-xs text-light/40 px-1">+{dayEvents.length - 3} more</p>
|
<p class="text-xs text-light/40 px-1">
|
||||||
|
+{dayEvents.length - 3} more
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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"
|
||||||
|
>
|
||||||
|
{#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="font-medium truncate">
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Day View -->
|
||||||
|
{#if currentView === "day"}
|
||||||
|
{@const dayEvents = getEventsForDay(currentDate)}
|
||||||
|
<div class="bg-dark rounded-lg p-4 min-h-[400px]">
|
||||||
|
{#if dayEvents.length === 0}
|
||||||
|
<div class="text-center text-light/40 py-12">
|
||||||
|
<p>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"
|
||||||
|
style="background-color: {event.color ??
|
||||||
|
'#6366f1'}20; border-left: 3px solid {event.color ??
|
||||||
|
'#6366f1'}"
|
||||||
|
onclick={() => onEventClick?.(event)}
|
||||||
|
>
|
||||||
|
<div class="font-medium text-light">
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-light/60 mt-1">
|
||||||
|
{new Date(event.start_time).toLocaleTimeString(
|
||||||
|
"en-US",
|
||||||
|
{ hour: "numeric", minute: "2-digit" },
|
||||||
|
)}
|
||||||
|
- {new Date(event.end_time).toLocaleTimeString(
|
||||||
|
"en-US",
|
||||||
|
{ hour: "numeric", minute: "2-digit" },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{#if event.description}
|
||||||
|
<div class="text-sm text-light/50 mt-2">
|
||||||
|
{event.description}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,18 +28,36 @@
|
|||||||
|
|
||||||
let element: HTMLDivElement;
|
let element: HTMLDivElement;
|
||||||
let editor: Editor | null = $state(null);
|
let editor: Editor | null = $state(null);
|
||||||
|
let saveStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function triggerAutoSave() {
|
function triggerAutoSave() {
|
||||||
if (saveTimeout) clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
saveTimeout = setTimeout(() => {
|
saveStatus = "idle";
|
||||||
if (editor && onSave) {
|
saveTimeout = setTimeout(async () => {
|
||||||
onSave(editor.getJSON());
|
await saveNow();
|
||||||
}
|
|
||||||
}, 1000); // Auto-save after 1 second of inactivity
|
}, 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor = new Editor({
|
editor = new Editor({
|
||||||
element,
|
element,
|
||||||
@@ -69,6 +87,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (saveTimeout) clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
|
if (statusTimeout) clearTimeout(statusTimeout);
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,6 +129,67 @@
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50"
|
class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50"
|
||||||
>
|
>
|
||||||
|
<!-- 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 ===
|
||||||
|
'saving'
|
||||||
|
? 'text-warning'
|
||||||
|
: saveStatus === 'saved'
|
||||||
|
? 'text-success'
|
||||||
|
: saveStatus === 'error'
|
||||||
|
? 'text-error'
|
||||||
|
: 'text-light/60 hover:text-light'}"
|
||||||
|
onclick={() => saveNow()}
|
||||||
|
disabled={saveStatus === "saving"}
|
||||||
|
title="Save (Ctrl+S)"
|
||||||
|
>
|
||||||
|
{#if saveStatus === "saving"}
|
||||||
|
<div
|
||||||
|
class="w-2 h-2 rounded-full bg-warning animate-pulse"
|
||||||
|
></div>
|
||||||
|
<span>Saving...</span>
|
||||||
|
{:else if saveStatus === "saved"}
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="20,6 9,17 4,12" />
|
||||||
|
</svg>
|
||||||
|
<span>Saved</span>
|
||||||
|
{:else if saveStatus === "error"}
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<span>Error</span>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"
|
||||||
|
/>
|
||||||
|
<polyline points="17,21 17,13 7,13 7,21" />
|
||||||
|
<polyline points="7,3 7,8 15,8" />
|
||||||
|
</svg>
|
||||||
|
<span>Save</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-5 bg-light/20 mr-1"></div>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
items: DocumentWithChildren[];
|
items: DocumentWithChildren[];
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
onSelect: (doc: DocumentWithChildren) => void;
|
onSelect: (doc: DocumentWithChildren) => void;
|
||||||
|
onDoubleClick?: (doc: DocumentWithChildren) => void;
|
||||||
onAdd?: (parentId: string | null) => void;
|
onAdd?: (parentId: string | null) => void;
|
||||||
onMove?: (docId: string, newParentId: string | null) => void;
|
onMove?: (docId: string, newParentId: string | null) => void;
|
||||||
onEdit?: (doc: DocumentWithChildren) => void;
|
onEdit?: (doc: DocumentWithChildren) => void;
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
items,
|
items,
|
||||||
selectedId = null,
|
selectedId = null,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onDoubleClick,
|
||||||
onAdd,
|
onAdd,
|
||||||
onMove,
|
onMove,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -92,6 +94,7 @@
|
|||||||
: 'text-light/80 hover:bg-light/5'}
|
: 'text-light/80 hover:bg-light/5'}
|
||||||
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
||||||
onclick={() => handleSelect(item)}
|
onclick={() => handleSelect(item)}
|
||||||
|
ondblclick={() => onDoubleClick?.(item)}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => handleDragStart(e, item)}
|
ondragstart={(e) => handleDragStart(e, item)}
|
||||||
ondragover={(e) =>
|
ondragover={(e) =>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ColumnWithCards } from '$lib/api/kanban';
|
import type { ColumnWithCards } from "$lib/api/kanban";
|
||||||
import type { KanbanCard } from '$lib/supabase/types';
|
import type { KanbanCard } from "$lib/supabase/types";
|
||||||
import { Button, Card, Badge } from '$lib/components/ui';
|
import { Button, Card, Badge } from "$lib/components/ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
columns: ColumnWithCards[];
|
columns: ColumnWithCards[];
|
||||||
onCardClick?: (card: KanbanCard) => void;
|
onCardClick?: (card: KanbanCard) => void;
|
||||||
onCardMove?: (cardId: string, toColumnId: string, toPosition: number) => void;
|
onCardMove?: (
|
||||||
|
cardId: string,
|
||||||
|
toColumnId: string,
|
||||||
|
toPosition: number,
|
||||||
|
) => void;
|
||||||
onAddCard?: (columnId: string) => void;
|
onAddCard?: (columnId: string) => void;
|
||||||
onAddColumn?: () => void;
|
onAddColumn?: () => void;
|
||||||
|
onDeleteCard?: (cardId: string) => void;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,17 +23,25 @@
|
|||||||
onCardMove,
|
onCardMove,
|
||||||
onAddCard,
|
onAddCard,
|
||||||
onAddColumn,
|
onAddColumn,
|
||||||
canEdit = true
|
onDeleteCard,
|
||||||
|
canEdit = true,
|
||||||
}: Props = $props();
|
}: 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 draggedCard = $state<KanbanCard | null>(null);
|
||||||
let dragOverColumn = $state<string | null>(null);
|
let dragOverColumn = $state<string | null>(null);
|
||||||
|
|
||||||
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
||||||
draggedCard = card;
|
draggedCard = card;
|
||||||
if (e.dataTransfer) {
|
if (e.dataTransfer) {
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = "move";
|
||||||
e.dataTransfer.setData('text/plain', card.id);
|
e.dataTransfer.setData("text/plain", card.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,37 +67,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDueDate(dateStr: string | null): string {
|
function formatDueDate(dateStr: string | null): string {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return "";
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date.getTime() - now.getTime();
|
const diff = date.getTime() - now.getTime();
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (days < 0) return 'Overdue';
|
if (days < 0) return "Overdue";
|
||||||
if (days === 0) return 'Today';
|
if (days === 0) return "Today";
|
||||||
if (days === 1) return 'Tomorrow';
|
if (days === 1) return "Tomorrow";
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDueDateColor(dateStr: string | null): 'error' | 'warning' | 'default' {
|
function getDueDateColor(
|
||||||
if (!dateStr) return 'default';
|
dateStr: string | null,
|
||||||
|
): "error" | "warning" | "default" {
|
||||||
|
if (!dateStr) return "default";
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date.getTime() - now.getTime();
|
const diff = date.getTime() - now.getTime();
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (days < 0) return 'error';
|
if (days < 0) return "error";
|
||||||
if (days <= 2) return 'warning';
|
if (days <= 2) return "warning";
|
||||||
return 'default';
|
return "default";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px]">
|
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px] scrollbar-visible">
|
||||||
{#each columns as column}
|
{#each columns as column}
|
||||||
<div
|
<div
|
||||||
class="flex-shrink-0 w-72 bg-surface rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)]"
|
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:ring-2={dragOverColumn === column.id}
|
column.id
|
||||||
class:ring-primary={dragOverColumn === column.id}
|
? 'ring-2 ring-primary bg-primary/5'
|
||||||
|
: ''}"
|
||||||
ondragover={(e) => handleDragOver(e, column.id)}
|
ondragover={(e) => handleDragOver(e, column.id)}
|
||||||
ondragleave={handleDragLeave}
|
ondragleave={handleDragLeave}
|
||||||
ondrop={(e) => handleDrop(e, column.id)}
|
ondrop={(e) => handleDrop(e, column.id)}
|
||||||
@@ -93,37 +109,71 @@
|
|||||||
<div class="flex items-center justify-between mb-3 px-1">
|
<div class="flex items-center justify-between mb-3 px-1">
|
||||||
<h3 class="font-medium text-light flex items-center gap-2">
|
<h3 class="font-medium text-light flex items-center gap-2">
|
||||||
{column.name}
|
{column.name}
|
||||||
<span class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded">
|
<span
|
||||||
|
class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded"
|
||||||
|
>
|
||||||
{column.cards.length}
|
{column.cards.length}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
{#if column.color}
|
{#if column.color}
|
||||||
<div class="w-3 h-3 rounded-full" style="background-color: {column.color}"></div>
|
<div
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
style="background-color: {column.color}"
|
||||||
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-2">
|
<div class="flex-1 overflow-y-auto space-y-2">
|
||||||
{#each column.cards as card}
|
{#each column.cards as card}
|
||||||
<div
|
<div
|
||||||
class="bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all"
|
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}
|
class:opacity-50={draggedCard?.id === card.id}
|
||||||
draggable={canEdit}
|
draggable={canEdit}
|
||||||
ondragstart={(e) => handleDragStart(e, card)}
|
ondragstart={(e) => handleDragStart(e, card)}
|
||||||
onclick={() => onCardClick?.(card)}
|
onclick={() => onCardClick?.(card)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onCardClick?.(card)}
|
onkeydown={(e) =>
|
||||||
|
e.key === "Enter" && onCardClick?.(card)}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{#if card.color}
|
{#if canEdit}
|
||||||
<div class="w-full h-1 rounded-full mb-2" style="background-color: {card.color}"></div>
|
<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}
|
||||||
<p class="text-sm text-light">{card.title}</p>
|
{#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}
|
{#if card.description}
|
||||||
<p class="text-xs text-light/50 mt-1 line-clamp-2">{card.description}</p>
|
<p class="text-xs text-light/50 mt-1 line-clamp-2">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if card.due_date}
|
{#if card.due_date}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<Badge size="sm" variant={getDueDateColor(card.due_date)}>
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={getDueDateColor(card.due_date)}
|
||||||
|
>
|
||||||
{formatDueDate(card.due_date)}
|
{formatDueDate(card.due_date)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +187,13 @@
|
|||||||
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"
|
class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1"
|
||||||
onclick={() => onAddCard?.(column.id)}
|
onclick={() => onAddCard?.(column.id)}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -152,7 +208,13 @@
|
|||||||
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"
|
class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors"
|
||||||
onclick={() => onAddColumn?.()}
|
onclick={() => onAddColumn?.()}
|
||||||
>
|
>
|
||||||
<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="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -160,3 +222,24 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-visible {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(229, 230, 240, 0.3) transparent;
|
||||||
|
}
|
||||||
|
.scrollbar-visible::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.scrollbar-visible::-webkit-scrollbar-track {
|
||||||
|
background: rgba(229, 230, 240, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.scrollbar-visible::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(229, 230, 240, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(229, 230, 240, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
14
src/lib/components/ui/ToastContainer.svelte
Normal file
14
src/lib/components/ui/ToastContainer.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
|
import Toast from './Toast.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
{#each $toasts as toast (toast.id)}
|
||||||
|
<Toast
|
||||||
|
variant={toast.variant}
|
||||||
|
message={toast.message}
|
||||||
|
onClose={() => toasts.remove(toast.id)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -9,3 +9,4 @@ export { default as Modal } from './Modal.svelte';
|
|||||||
export { default as Spinner } from './Spinner.svelte';
|
export { default as Spinner } from './Spinner.svelte';
|
||||||
export { default as Toggle } from './Toggle.svelte';
|
export { default as Toggle } from './Toggle.svelte';
|
||||||
export { default as Toast } from './Toast.svelte';
|
export { default as Toast } from './Toast.svelte';
|
||||||
|
export { default as ToastContainer } from './ToastContainer.svelte';
|
||||||
|
|||||||
48
src/lib/stores/toast.ts
Normal file
48
src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
variant: ToastVariant;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToastStore() {
|
||||||
|
const { subscribe, update } = writable<Toast[]>([]);
|
||||||
|
|
||||||
|
function add(message: string, variant: ToastVariant = 'info', duration = 5000) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const toast: Toast = { id, message, variant, duration };
|
||||||
|
|
||||||
|
update((toasts) => [...toasts, toast]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => remove(id), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
update(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
success: (message: string, duration?: number) => add(message, 'success', duration),
|
||||||
|
error: (message: string, duration?: number) => add(message, 'error', duration),
|
||||||
|
warning: (message: string, duration?: number) => add(message, 'warning', duration),
|
||||||
|
info: (message: string, duration?: number) => add(message, 'info', duration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toasts = createToastStore();
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import favicon from "$lib/assets/favicon.svg";
|
import favicon from "$lib/assets/favicon.svg";
|
||||||
import { createClient } from "$lib/supabase";
|
import { createClient } from "$lib/supabase";
|
||||||
import { setContext } from "svelte";
|
import { setContext } from "svelte";
|
||||||
|
import { ToastContainer } from "$lib/components/ui";
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -12,3 +13,4 @@
|
|||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<ToastContainer />
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Organizations | Root</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-dark">
|
<div class="min-h-screen bg-dark">
|
||||||
<header class="border-b border-light/10 bg-surface">
|
<header class="border-b border-light/10 bg-surface">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -46,10 +46,30 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
.eq('org_id', org.id)
|
.eq('org_id', org.id)
|
||||||
.limit(10);
|
.limit(10);
|
||||||
|
|
||||||
|
// Fetch recent activity
|
||||||
|
const { data: recentActivity } = await locals.supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
entity_name,
|
||||||
|
created_at,
|
||||||
|
profiles:user_id (
|
||||||
|
full_name,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('org_id', org.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
org,
|
org,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
userRole: membership.role,
|
userRole: membership.role,
|
||||||
members: members ?? []
|
members: members ?? [],
|
||||||
|
recentActivity: recentActivity ?? []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Card } from "$lib/components/ui";
|
import { Card } from "$lib/components/ui";
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
profiles: { full_name: string | null; email: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
@@ -11,6 +20,7 @@
|
|||||||
role: string;
|
role: string;
|
||||||
profiles: { full_name: string | null; email: string };
|
profiles: { full_name: string | null; email: string };
|
||||||
}>;
|
}>;
|
||||||
|
recentActivity?: ActivityItem[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,30 +47,51 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock recent activity - will be replaced with real data from activity_log table
|
// Get icon based on entity type
|
||||||
const recentActivity = [
|
function getActivityIcon(entityType: string): string {
|
||||||
{
|
switch (entityType) {
|
||||||
id: "1",
|
case "document":
|
||||||
action: "Created document",
|
return "file";
|
||||||
entity: "Project Brief",
|
case "kanban_card":
|
||||||
time: "2 hours ago",
|
case "kanban_board":
|
||||||
icon: "file",
|
return "kanban";
|
||||||
},
|
case "calendar_event":
|
||||||
{
|
return "calendar";
|
||||||
id: "2",
|
case "member":
|
||||||
action: "Updated kanban card",
|
return "user";
|
||||||
entity: "Design Review",
|
default:
|
||||||
time: "4 hours ago",
|
return "activity";
|
||||||
icon: "kanban",
|
}
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: "3",
|
// Format relative time
|
||||||
action: "Added team member",
|
function formatRelativeTime(dateStr: string): string {
|
||||||
entity: "New Developer",
|
const date = new Date(dateStr);
|
||||||
time: "1 day ago",
|
const now = new Date();
|
||||||
icon: "user",
|
const diff = now.getTime() - date.getTime();
|
||||||
},
|
const minutes = Math.floor(diff / 60000);
|
||||||
];
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "Just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format action text
|
||||||
|
function formatAction(action: string, entityType: string): string {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
document: "document",
|
||||||
|
kanban_card: "task",
|
||||||
|
kanban_board: "board",
|
||||||
|
calendar_event: "event",
|
||||||
|
member: "member",
|
||||||
|
};
|
||||||
|
const type = typeMap[entityType] || entityType;
|
||||||
|
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${type}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -148,15 +179,17 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-xl font-heading text-light mb-4">Recent Activity</h2>
|
<h2 class="text-xl font-heading text-light mb-4">Recent Activity</h2>
|
||||||
<Card>
|
<Card>
|
||||||
|
{#if data.recentActivity && data.recentActivity.length > 0}
|
||||||
<div class="divide-y divide-light/10">
|
<div class="divide-y divide-light/10">
|
||||||
{#each recentActivity as activity}
|
{#each data.recentActivity as activity}
|
||||||
|
{@const icon = getActivityIcon(activity.entity_type)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-4 p-4 hover:bg-light/5 transition-colors"
|
class="flex items-center gap-4 p-4 hover:bg-light/5 transition-colors"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"
|
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"
|
||||||
>
|
>
|
||||||
{#if activity.icon === "file"}
|
{#if icon === "file"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5 text-primary"
|
class="w-5 h-5 text-primary"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -169,7 +202,7 @@
|
|||||||
/>
|
/>
|
||||||
<polyline points="14,2 14,8 20,8" />
|
<polyline points="14,2 14,8 20,8" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if activity.icon === "kanban"}
|
{:else if icon === "kanban"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5 text-primary"
|
class="w-5 h-5 text-primary"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -187,7 +220,26 @@
|
|||||||
<line x1="9" y1="3" x2="9" y2="21" />
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
<line x1="15" y1="3" x2="15" y2="21" />
|
<line x1="15" y1="3" x2="15" y2="21" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if activity.icon === "user"}
|
{:else if icon === "calendar"}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
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>
|
||||||
|
{:else if icon === "user"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5 text-primary"
|
class="w-5 h-5 text-primary"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -200,22 +252,41 @@
|
|||||||
/>
|
/>
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12,6 12,12 16,14" />
|
||||||
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-light font-medium">
|
<p class="text-light font-medium">
|
||||||
{activity.action}
|
{formatAction(
|
||||||
|
activity.action,
|
||||||
|
activity.entity_type,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-light/50 truncate">
|
<p class="text-sm text-light/50 truncate">
|
||||||
{activity.entity}
|
{activity.entity_name || "Unknown"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-light/40 shrink-0"
|
<span class="text-xs text-light/40 shrink-0"
|
||||||
>{activity.time}</span
|
>{formatRelativeTime(activity.created_at)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-6 text-center text-light/50">
|
||||||
|
<p>No recent activity</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Calendar - {data.org.name} | Root</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-6 h-full overflow-auto">
|
<div class="p-6 h-full overflow-auto">
|
||||||
<header class="flex items-center justify-between mb-6">
|
<header class="flex items-center justify-between mb-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -37,6 +37,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick(doc: Document) {
|
||||||
|
if (doc.type === "document") {
|
||||||
|
// Open document in new window
|
||||||
|
const url = `/${data.org.slug}/documents/${doc.id}`;
|
||||||
|
window.open(url, "_blank", "width=900,height=700");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleAdd(folderId: string | null) {
|
function handleAdd(folderId: string | null) {
|
||||||
parentFolderId = folderId;
|
parentFolderId = folderId;
|
||||||
showCreateModal = true;
|
showCreateModal = true;
|
||||||
@@ -199,6 +207,7 @@
|
|||||||
items={documentTree}
|
items={documentTree}
|
||||||
selectedId={selectedDoc?.id ?? null}
|
selectedId={selectedDoc?.id ?? null}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let editingBoardId = $state<string | null>(null);
|
let editingBoardId = $state<string | null>(null);
|
||||||
|
let showAddColumnModal = $state(false);
|
||||||
|
let newColumnName = $state("");
|
||||||
|
let sidebarCollapsed = $state(false);
|
||||||
|
|
||||||
function openEditBoardModal(board: KanbanBoardType) {
|
function openEditBoardModal(board: KanbanBoardType) {
|
||||||
editingBoardId = board.id;
|
editingBoardId = board.id;
|
||||||
@@ -105,6 +108,38 @@
|
|||||||
showCardDetailModal = true;
|
showCardDetailModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddColumn() {
|
||||||
|
newColumnName = "";
|
||||||
|
showAddColumnModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateColumn() {
|
||||||
|
if (!selectedBoard || !newColumnName.trim()) return;
|
||||||
|
|
||||||
|
const position = selectedBoard.columns.length;
|
||||||
|
const { data: newColumn, error } = await supabase
|
||||||
|
.from("kanban_columns")
|
||||||
|
.insert({
|
||||||
|
board_id: selectedBoard.id,
|
||||||
|
name: newColumnName.trim(),
|
||||||
|
position,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!error && newColumn && selectedBoard) {
|
||||||
|
selectedBoard = {
|
||||||
|
...selectedBoard,
|
||||||
|
columns: [
|
||||||
|
...selectedBoard.columns,
|
||||||
|
{ ...newColumn, cards: [] as KanbanCard[] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
showAddColumnModal = false;
|
||||||
|
newColumnName = "";
|
||||||
|
}
|
||||||
|
|
||||||
function handleCardCreated(newCard: KanbanCard) {
|
function handleCardCreated(newCard: KanbanCard) {
|
||||||
if (!selectedBoard) return;
|
if (!selectedBoard) return;
|
||||||
selectedBoard = {
|
selectedBoard = {
|
||||||
@@ -173,7 +208,23 @@
|
|||||||
|
|
||||||
async function handleCardDelete(cardId: string) {
|
async function handleCardDelete(cardId: string) {
|
||||||
if (!selectedBoard) return;
|
if (!selectedBoard) return;
|
||||||
await loadBoard(selectedBoard.id);
|
|
||||||
|
// Delete from database
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("kanban_cards")
|
||||||
|
.delete()
|
||||||
|
.eq("id", cardId);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
// Update local state
|
||||||
|
selectedBoard = {
|
||||||
|
...selectedBoard,
|
||||||
|
columns: selectedBoard.columns.map((col) => ({
|
||||||
|
...col,
|
||||||
|
cards: col.cards.filter((c) => c.id !== cardId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -185,11 +236,18 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<aside class="w-64 border-r border-light/10 flex flex-col">
|
<aside
|
||||||
<div
|
class="{sidebarCollapsed
|
||||||
class="p-4 border-b border-light/10 flex items-center justify-between"
|
? 'w-12'
|
||||||
|
: 'w-64'} border-r border-light/10 flex flex-col transition-all duration-200"
|
||||||
>
|
>
|
||||||
<h2 class="font-semibold text-light">Boards</h2>
|
<div
|
||||||
|
class="p-2 border-b border-light/10 flex items-center {sidebarCollapsed
|
||||||
|
? 'justify-center'
|
||||||
|
: 'justify-between gap-2'}"
|
||||||
|
>
|
||||||
|
{#if !sidebarCollapsed}
|
||||||
|
<h2 class="font-semibold text-light px-2">Boards</h2>
|
||||||
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
|
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
@@ -202,6 +260,24 @@
|
|||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-lg hover:bg-light/10 text-light/50 hover:text-light transition-colors"
|
||||||
|
onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
|
||||||
|
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 transition-transform {sidebarCollapsed
|
||||||
|
? 'rotate-180'
|
||||||
|
: ''}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="m11 17-5-5 5-5M17 17l-5-5 5-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
@@ -285,6 +361,8 @@
|
|||||||
onCardClick={handleCardClick}
|
onCardClick={handleCardClick}
|
||||||
onCardMove={handleCardMove}
|
onCardMove={handleCardMove}
|
||||||
onAddCard={handleAddCard}
|
onAddCard={handleAddCard}
|
||||||
|
onAddColumn={handleAddColumn}
|
||||||
|
onDeleteCard={handleCardDelete}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-full flex items-center justify-center text-light/40">
|
<div class="h-full flex items-center justify-center text-light/40">
|
||||||
@@ -352,6 +430,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showAddColumnModal}
|
||||||
|
onClose={() => (showAddColumnModal = false)}
|
||||||
|
title="Add Column"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Column Name"
|
||||||
|
bind:value={newColumnName}
|
||||||
|
placeholder="e.g. To Do, In Progress, Done"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onclick={() => (showAddColumnModal = false)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onclick={handleCreateColumn}
|
||||||
|
disabled={!newColumnName.trim()}>Create</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<CardDetailModal
|
<CardDetailModal
|
||||||
card={selectedCard}
|
card={selectedCard}
|
||||||
isOpen={showCardDetailModal}
|
isOpen={showCardDetailModal}
|
||||||
|
|||||||
@@ -417,8 +417,33 @@
|
|||||||
await supabase.from("organizations").delete().eq("id", data.org.id);
|
await supabase.from("organizations").delete().eq("id", data.org.id);
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function leaveOrganization() {
|
||||||
|
if (isOwner) {
|
||||||
|
alert(
|
||||||
|
"Owners cannot leave. Transfer ownership first or delete the organization.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("org_members")
|
||||||
|
.delete()
|
||||||
|
.eq("org_id", data.org.id)
|
||||||
|
.eq("user_id", data.user?.id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings - {data.org.name} | Root</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-6 h-full overflow-auto">
|
<div class="p-6 h-full overflow-auto">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 class="text-2xl font-bold text-light">Settings</h1>
|
<h1 class="text-2xl font-bold text-light">Settings</h1>
|
||||||
@@ -512,6 +537,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{#if !isOwner}
|
||||||
|
<Card>
|
||||||
|
<div class="p-6 border-l-4 border-warning">
|
||||||
|
<h2 class="text-lg font-semibold text-warning">
|
||||||
|
Leave Organization
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-light/50 mt-1">
|
||||||
|
Leave this organization. You will need to be
|
||||||
|
re-invited to rejoin.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={leaveOrganization}
|
||||||
|
>Leave {data.org.name}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<Card>
|
<Card>
|
||||||
<div class="p-6 border-l-4 border-error">
|
<div class="p-6 border-l-4 border-error">
|
||||||
|
|||||||
69
supabase/migrations/010_tags_system.sql
Normal file
69
supabase/migrations/010_tags_system.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- Tags system for kanban tasks
|
||||||
|
-- Allows categorizing tasks by team, type, priority, etc.
|
||||||
|
|
||||||
|
-- Create tags table
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT DEFAULT '#00A3E0',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(org_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create junction table for card tags (many-to-many)
|
||||||
|
CREATE TABLE IF NOT EXISTS card_tags (
|
||||||
|
card_id UUID NOT NULL REFERENCES kanban_cards(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (card_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_org ON tags(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_tags_card ON card_tags(card_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_tags_tag ON card_tags(tag_id);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE card_tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- RLS policies for tags
|
||||||
|
CREATE POLICY "Users can view tags in their orgs"
|
||||||
|
ON tags FOR SELECT
|
||||||
|
USING (
|
||||||
|
org_id IN (
|
||||||
|
SELECT org_id FROM org_members WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can manage tags"
|
||||||
|
ON tags FOR ALL
|
||||||
|
USING (
|
||||||
|
org_id IN (
|
||||||
|
SELECT org_id FROM org_members
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND role IN ('owner', 'admin')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS policies for card_tags
|
||||||
|
CREATE POLICY "Users can view card tags in their orgs"
|
||||||
|
ON card_tags FOR SELECT
|
||||||
|
USING (
|
||||||
|
tag_id IN (
|
||||||
|
SELECT id FROM tags WHERE org_id IN (
|
||||||
|
SELECT org_id FROM org_members WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Members can manage card tags"
|
||||||
|
ON card_tags FOR ALL
|
||||||
|
USING (
|
||||||
|
tag_id IN (
|
||||||
|
SELECT id FROM tags WHERE org_id IN (
|
||||||
|
SELECT org_id FROM org_members WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
83
supabase/migrations/011_teams_roles.sql
Normal file
83
supabase/migrations/011_teams_roles.sql
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
-- Teams/Roles system for organization structure
|
||||||
|
-- Like TipiLAN: infra team, sponsors, etc.
|
||||||
|
|
||||||
|
-- Create teams table
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color TEXT DEFAULT '#00A3E0',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(org_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create team members junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
role TEXT DEFAULT 'member' CHECK (role IN ('lead', 'member')),
|
||||||
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (team_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teams_org ON teams(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- RLS policies for teams
|
||||||
|
CREATE POLICY "Users can view teams in their orgs"
|
||||||
|
ON teams FOR SELECT
|
||||||
|
USING (
|
||||||
|
org_id IN (
|
||||||
|
SELECT org_id FROM org_members WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can manage teams"
|
||||||
|
ON teams FOR ALL
|
||||||
|
USING (
|
||||||
|
org_id IN (
|
||||||
|
SELECT org_id FROM org_members
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND role IN ('owner', 'admin')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS policies for team_members
|
||||||
|
CREATE POLICY "Users can view team members in their orgs"
|
||||||
|
ON team_members FOR SELECT
|
||||||
|
USING (
|
||||||
|
team_id IN (
|
||||||
|
SELECT id FROM teams WHERE org_id IN (
|
||||||
|
SELECT org_id FROM org_members WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can manage team members"
|
||||||
|
ON team_members FOR ALL
|
||||||
|
USING (
|
||||||
|
team_id IN (
|
||||||
|
SELECT id FROM teams WHERE org_id IN (
|
||||||
|
SELECT org_id FROM org_members
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND role IN ('owner', 'admin')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add team_id to kanban_boards for team-specific boards
|
||||||
|
ALTER TABLE kanban_boards ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE kanban_boards ADD COLUMN IF NOT EXISTS is_personal BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE kanban_boards ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES profiles(id);
|
||||||
|
|
||||||
|
-- Create index for team boards
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kanban_boards_team ON kanban_boards(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kanban_boards_personal ON kanban_boards(is_personal, created_by);
|
||||||
Reference in New Issue
Block a user