Mega push vol2

master
AlacrisDevs 2 days ago
parent 1534e1f0af
commit 9af0ef5307
  1. 267
      src/lib/components/calendar/Calendar.svelte
  2. 86
      src/lib/components/documents/Editor.svelte
  3. 3
      src/lib/components/documents/FileTree.svelte
  4. 143
      src/lib/components/kanban/KanbanBoard.svelte
  5. 14
      src/lib/components/ui/ToastContainer.svelte
  6. 1
      src/lib/components/ui/index.ts
  7. 48
      src/lib/stores/toast.ts
  8. 2
      src/routes/+layout.svelte
  9. 4
      src/routes/+page.svelte
  10. 22
      src/routes/[orgSlug]/+layout.server.ts
  11. 133
      src/routes/[orgSlug]/+page.svelte
  12. 4
      src/routes/[orgSlug]/calendar/+page.svelte
  13. 9
      src/routes/[orgSlug]/documents/+page.svelte
  14. 109
      src/routes/[orgSlug]/kanban/+page.svelte
  15. 46
      src/routes/[orgSlug]/settings/+page.svelte
  16. 69
      supabase/migrations/010_tags_system.sql
  17. 83
      supabase/migrations/011_teams_roles.sql

@ -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,16 +28,34 @@
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";
saveTimeout = setTimeout(async () => {
await saveNow();
}, 1000); // Auto-save after 1 second of inactivity
}
async function saveNow() {
if (editor && onSave) { if (editor && onSave) {
onSave(editor.getJSON()); 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";
}
} }
}, 1000); // Auto-save after 1 second of inactivity
} }
onMount(() => { onMount(() => {
@ -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 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} {#if card.color}
<div class="w-full h-1 rounded-full mb-2" style="background-color: {card.color}"></div> <div
class="w-full h-1 rounded-full mb-2"
style="background-color: {card.color}"
></div>
{/if} {/if}
<p class="text-sm text-light">{card.title}</p> <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>

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

@ -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
class="{sidebarCollapsed
? 'w-12'
: 'w-64'} border-r border-light/10 flex flex-col transition-all duration-200"
>
<div <div
class="p-4 border-b border-light/10 flex items-center justify-between" class="p-2 border-b border-light/10 flex items-center {sidebarCollapsed
? 'justify-center'
: 'justify-between gap-2'}"
> >
<h2 class="font-semibold text-light">Boards</h2> {#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">

@ -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()
)
)
);

@ -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);
Loading…
Cancel
Save