Mega push vol 3

master
AlacrisDevs 2 days ago
parent 9af0ef5307
commit b517bb975c
  1. 311
      src/lib/components/kanban/CardDetailModal.svelte
  2. 75
      src/lib/components/kanban/KanbanBoard.svelte
  3. 222
      src/lib/stores/theme.ts
  4. 20
      src/routes/[orgSlug]/kanban/+page.svelte
  5. 224
      src/routes/[orgSlug]/settings/+page.svelte
  6. 53
      supabase/migrations/012_task_comments.sql

@ -13,6 +13,26 @@
position: number; position: number;
} }
interface Comment {
id: string;
card_id: string;
user_id: string;
content: string;
created_at: string;
profiles?: { full_name: string | null; email: string };
}
interface Member {
id: string;
user_id: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props { interface Props {
card: KanbanCard | null; card: KanbanCard | null;
isOpen: boolean; isOpen: boolean;
@ -23,6 +43,7 @@
columnId?: string; columnId?: string;
userId?: string; userId?: string;
onCreate?: (card: KanbanCard) => void; onCreate?: (card: KanbanCard) => void;
members?: Member[];
} }
let { let {
@ -35,6 +56,7 @@
columnId, columnId,
userId, userId,
onCreate, onCreate,
members = [],
}: Props = $props(); }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
@ -42,20 +64,38 @@
let title = $state(""); let title = $state("");
let description = $state(""); let description = $state("");
let checklist = $state<ChecklistItem[]>([]); let checklist = $state<ChecklistItem[]>([]);
let comments = $state<Comment[]>([]);
let newItemTitle = $state(""); let newItemTitle = $state("");
let newComment = $state("");
let assigneeId = $state<string | null>(null);
let dueDate = $state<string>("");
let priority = $state<string>("medium");
let isLoading = $state(false); let isLoading = $state(false);
let isSaving = $state(false); let isSaving = $state(false);
let showAssigneePicker = $state(false);
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
if (mode === "edit" && card) { if (mode === "edit" && card) {
title = card.title; title = card.title;
description = card.description ?? ""; description = card.description ?? "";
assigneeId = (card as any).assignee_id ?? null;
dueDate = (card as any).due_date
? new Date((card as any).due_date)
.toISOString()
.split("T")[0]
: "";
priority = (card as any).priority ?? "medium";
loadChecklist(); loadChecklist();
loadComments();
} else if (mode === "create") { } else if (mode === "create") {
title = ""; title = "";
description = ""; description = "";
assigneeId = null;
dueDate = "";
priority = "medium";
checklist = []; checklist = [];
comments = [];
} }
} }
}); });
@ -65,7 +105,7 @@
isLoading = true; isLoading = true;
const { data } = await supabase const { data } = await supabase
.from("checklist_items") .from("kanban_checklist_items")
.select("*") .select("*")
.eq("card_id", card.id) .eq("card_id", card.id)
.order("position"); .order("position");
@ -74,6 +114,27 @@
isLoading = false; isLoading = false;
} }
async function loadComments() {
if (!card) return;
const { data } = await supabase
.from("kanban_comments")
.select(
`
id,
card_id,
user_id,
content,
created_at,
profiles:user_id (full_name, email)
`,
)
.eq("card_id", card.id)
.order("created_at", { ascending: true });
comments = (data ?? []) as Comment[];
}
async function handleSave() { async function handleSave() {
if (mode === "create") { if (mode === "create") {
await handleCreate(); await handleCreate();
@ -88,11 +149,21 @@
.update({ .update({
title, title,
description: description || null, description: description || null,
assignee_id: assigneeId,
due_date: dueDate || null,
priority,
}) })
.eq("id", card.id); .eq("id", card.id);
if (!error) { if (!error) {
onUpdate({ ...card, title, description: description || null }); onUpdate({
...card,
title,
description: description || null,
assignee_id: assigneeId,
due_date: dueDate || null,
priority,
} as KanbanCard);
} }
isSaving = false; isSaving = false;
} }
@ -133,7 +204,7 @@
const position = checklist.length; const position = checklist.length;
const { data, error } = await supabase const { data, error } = await supabase
.from("checklist_items") .from("kanban_checklist_items")
.insert({ .insert({
card_id: card.id, card_id: card.id,
title: newItemTitle, title: newItemTitle,
@ -149,9 +220,37 @@
} }
} }
async function handleAddComment() {
if (!card || !newComment.trim() || !userId) return;
const { data, error } = await supabase
.from("kanban_comments")
.insert({
card_id: card.id,
user_id: userId,
content: newComment,
})
.select(
`
id,
card_id,
user_id,
content,
created_at,
profiles:user_id (full_name, email)
`,
)
.single();
if (!error && data) {
comments = [...comments, data as Comment];
newComment = "";
}
}
async function toggleItem(item: ChecklistItem) { async function toggleItem(item: ChecklistItem) {
const { error } = await supabase const { error } = await supabase
.from("checklist_items") .from("kanban_checklist_items")
.update({ completed: !item.completed }) .update({ completed: !item.completed })
.eq("id", item.id); .eq("id", item.id);
@ -164,7 +263,7 @@
async function deleteItem(itemId: string) { async function deleteItem(itemId: string) {
const { error } = await supabase const { error } = await supabase
.from("checklist_items") .from("kanban_checklist_items")
.delete() .delete()
.eq("id", itemId); .eq("id", itemId);
@ -173,6 +272,20 @@
} }
} }
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function getAssignee(id: string | null) {
if (!id) return null;
return members.find((m) => m.user_id === id);
}
async function handleDelete() { async function handleDelete() {
if (!card || !confirm("Delete this card?")) return; if (!card || !confirm("Delete this card?")) return;
@ -207,6 +320,129 @@
rows={3} rows={3}
/> />
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<!-- Assignee -->
<div class="relative">
<label class="block text-sm font-medium text-light mb-1"
>Assignee</label
>
<button
type="button"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-left text-sm flex items-center gap-2 hover:border-light/40 transition-colors"
onclick={() =>
(showAssigneePicker = !showAssigneePicker)}
>
{#if assigneeId && getAssignee(assigneeId)}
{@const assignee = getAssignee(assigneeId)}
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs text-primary"
>
{(assignee?.profiles.full_name ||
assignee?.profiles.email ||
"?")[0].toUpperCase()}
</div>
<span class="text-light truncate"
>{assignee?.profiles.full_name ||
assignee?.profiles.email}</span
>
{:else}
<div
class="w-6 h-6 rounded-full bg-light/10 flex items-center justify-center"
>
<svg
class="w-3 h-3 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
/>
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<span class="text-light/40">Unassigned</span>
{/if}
</button>
{#if showAssigneePicker}
<div
class="absolute top-full left-0 right-0 mt-1 bg-dark border border-light/20 rounded-lg shadow-lg z-10 max-h-48 overflow-y-auto"
>
<button
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
onclick={() => {
assigneeId = null;
showAssigneePicker = false;
}}
>
<div
class="w-6 h-6 rounded-full bg-light/10"
></div>
Unassigned
</button>
{#each members as member}
<button
class="w-full px-3 py-2 text-left text-sm hover:bg-light/5 flex items-center gap-2 {assigneeId ===
member.user_id
? 'bg-primary/10 text-primary'
: 'text-light'}"
onclick={() => {
assigneeId = member.user_id;
showAssigneePicker = false;
}}
>
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs"
>
{(member.profiles.full_name ||
member.profiles.email ||
"?")[0].toUpperCase()}
</div>
{member.profiles.full_name ||
member.profiles.email}
</button>
{/each}
</div>
{/if}
</div>
<!-- Due Date -->
<div>
<label
for="due-date"
class="block text-sm font-medium text-light mb-1"
>Due Date</label
>
<input
id="due-date"
type="date"
bind:value={dueDate}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
/>
</div>
<!-- Priority -->
<div>
<label
for="priority"
class="block text-sm font-medium text-light mb-1"
>Priority</label
>
<select
id="priority"
bind:value={priority}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div> <div>
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-light" <label class="text-sm font-medium text-light"
@ -302,6 +538,71 @@
{/if} {/if}
</div> </div>
<!-- Comments Section -->
{#if mode === "edit"}
<div>
<label class="block text-sm font-medium text-light mb-3"
>Comments</label
>
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
{#each comments as comment}
<div class="flex gap-3">
<div
class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary"
>
{((comment.profiles as any)?.full_name ||
(comment.profiles as any)?.email ||
"?")[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-light"
>
{(comment.profiles as any)
?.full_name ||
(comment.profiles as any)
?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40"
>{formatDate(
comment.created_at,
)}</span
>
</div>
<p class="text-sm text-light/70 mt-0.5">
{comment.content}
</p>
</div>
</div>
{/each}
{#if comments.length === 0}
<p class="text-sm text-light/40 text-center py-2">
No comments yet
</p>
{/if}
</div>
<div class="flex gap-2">
<input
type="text"
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
placeholder="Add a comment..."
bind:value={newComment}
onkeydown={(e) =>
e.key === "Enter" && handleAddComment()}
/>
<Button
size="sm"
onclick={handleAddComment}
disabled={!newComment.trim()}
>
Send
</Button>
</div>
</div>
{/if}
<div <div
class="flex items-center justify-between pt-3 border-t border-light/10" class="flex items-center justify-between pt-3 border-t border-light/10"
> >

@ -14,6 +14,7 @@
onAddCard?: (columnId: string) => void; onAddCard?: (columnId: string) => void;
onAddColumn?: () => void; onAddColumn?: () => void;
onDeleteCard?: (cardId: string) => void; onDeleteCard?: (cardId: string) => void;
onDeleteColumn?: (columnId: string) => void;
canEdit?: boolean; canEdit?: boolean;
} }
@ -24,6 +25,7 @@
onAddCard, onAddCard,
onAddColumn, onAddColumn,
onDeleteCard, onDeleteCard,
onDeleteColumn,
canEdit = true, canEdit = true,
}: Props = $props(); }: Props = $props();
@ -115,12 +117,34 @@
{column.cards.length} {column.cards.length}
</span> </span>
</h3> </h3>
<div class="flex items-center gap-1">
{#if column.color} {#if column.color}
<div <div
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full"
style="background-color: {column.color}" style="background-color: {column.color}"
></div> ></div>
{/if} {/if}
{#if canEdit}
<button
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
onclick={() => onDeleteColumn?.(column.id)}
title="Delete column"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3,6 5,6 21,6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
</button>
{/if}
</div>
</div> </div>
<div class="flex-1 overflow-y-auto space-y-2"> <div class="flex-1 overflow-y-auto space-y-2">
@ -168,14 +192,63 @@
{card.description} {card.description}
</p> </p>
{/if} {/if}
{#if card.due_date || (card as any).checklist_total > 0 || (card as any).assignee_id}
<div class="mt-2 flex items-center gap-2 flex-wrap">
{#if card.due_date} {#if card.due_date}
<div class="mt-2">
<Badge <Badge
size="sm" size="sm"
variant={getDueDateColor(card.due_date)} variant={getDueDateColor(card.due_date)}
> >
{formatDueDate(card.due_date)} {formatDueDate(card.due_date)}
</Badge> </Badge>
{/if}
{#if (card as any).checklist_total > 0}
<span
class="text-xs flex items-center gap-1 {(
card as any
).checklist_done ===
(card as any).checklist_total
? 'text-success'
: 'text-light/50'}"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline
points="9,11 12,14 22,4"
/>
<path
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
/>
</svg>
{(card as any).checklist_done}/{(
card as any
).checklist_total}
</span>
{/if}
{#if (card as any).assignee_id}
<div
class="w-5 h-5 rounded-full bg-primary/30 flex items-center justify-center text-[10px] text-primary ml-auto"
title="Assigned"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
/>
<circle cx="12" cy="7" r="4" />
</svg>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

@ -0,0 +1,222 @@
/**
* Theme Store - Manages app theme (dark/light mode and accent colors)
* Inspired by root-v2
*/
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
export type ThemeMode = 'dark' | 'light' | 'system';
export interface ThemeColors {
primary: string;
name: string;
}
export const PRESET_COLORS: ThemeColors[] = [
{ name: 'Cyan', primary: '#00A3E0' },
{ name: 'Purple', primary: '#8B5CF6' },
{ name: 'Pink', primary: '#EC4899' },
{ name: 'Green', primary: '#10B981' },
{ name: 'Orange', primary: '#F97316' },
{ name: 'Red', primary: '#EF4444' },
{ name: 'Blue', primary: '#3B82F6' },
{ name: 'Indigo', primary: '#6366F1' },
];
const THEME_STORAGE_KEY = 'root_theme';
interface ThemeState {
mode: ThemeMode;
primaryColor: string;
}
const defaultTheme: ThemeState = {
mode: 'dark',
primaryColor: '#00A3E0',
};
// Convert hex to HSL
function hexToHSL(hex: string): { h: number; s: number; l: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { h: 0, s: 0, l: 0 };
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to hex
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
// Generate derived colors from primary
function generateDerivedColors(primary: string, isDark: boolean) {
const { h, s } = hexToHSL(primary);
if (isDark) {
return {
night: hslToHex(h, Math.min(s, 40), 6),
dark: hslToHex(h, Math.min(s, 35), 10),
surface: hslToHex(h, Math.min(s, 30), 12),
background: hslToHex(h, Math.min(s, 30), 3),
light: '#e5e6f0',
text: '#ffffff',
};
} else {
const lightSat = Math.min(s, 30);
return {
night: hslToHex(h, lightSat, 95),
dark: hslToHex(h, lightSat, 90),
surface: hslToHex(h, lightSat, 98),
background: hslToHex(h, lightSat, 100),
light: '#1a1a2e',
text: '#0a121f',
};
}
}
function getEffectiveMode(mode: ThemeMode): 'dark' | 'light' {
if (mode === 'system') {
if (!browser) return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return mode;
}
function loadTheme(): ThemeState {
if (!browser) return defaultTheme;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored) {
return { ...defaultTheme, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to load theme:', e);
}
return defaultTheme;
}
function saveTheme(theme: ThemeState): void {
if (!browser) return;
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme));
}
export function applyTheme(state: ThemeState): void {
if (!browser) return;
const root = document.documentElement;
const effectiveMode = getEffectiveMode(state.mode);
// Set mode class
root.classList.remove('dark', 'light');
root.classList.add(effectiveMode);
// Set CSS custom properties
root.style.setProperty('--color-primary', state.primaryColor);
// Calculate hover variant
const { h, s, l } = hexToHSL(state.primaryColor);
root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10)));
// Generate and apply derived colors
const derived = generateDerivedColors(state.primaryColor, effectiveMode === 'dark');
root.style.setProperty('--color-night', derived.night);
root.style.setProperty('--color-dark', derived.dark);
root.style.setProperty('--color-surface', derived.surface);
root.style.setProperty('--color-background', derived.background);
root.style.setProperty('--color-light', derived.light);
root.style.setProperty('--color-text', derived.text);
}
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeState>(loadTheme());
return {
subscribe,
setMode: (mode: ThemeMode) => {
update(state => {
const newState = { ...state, mode };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
setPrimaryColor: (color: string) => {
update(state => {
const newState = { ...state, primaryColor: color };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
toggleMode: () => {
update(state => {
const modes: ThemeMode[] = ['dark', 'light', 'system'];
const currentIndex = modes.indexOf(state.mode);
const newMode = modes[(currentIndex + 1) % modes.length];
const newState: ThemeState = { ...state, mode: newMode };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
reset: () => {
set(defaultTheme);
saveTheme(defaultTheme);
applyTheme(defaultTheme);
},
init: () => {
const state = loadTheme();
applyTheme(state);
set(state);
}
};
}
export const theme = createThemeStore();
// Derived stores for convenience
export const isDarkMode = derived(theme, $t => getEffectiveMode($t.mode) === 'dark');
export const primaryColor = derived(theme, $t => $t.primaryColor);
export const themeMode = derived(theme, $t => $t.mode);
// Initialize theme on load
if (browser) {
applyTheme(loadTheme());
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const state = loadTheme();
if (state.mode === 'system') {
applyTheme(state);
}
});
}

@ -35,6 +35,8 @@
let selectedCard = $state<KanbanCard | null>(null); let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state(""); let newBoardName = $state("");
let editBoardName = $state(""); let editBoardName = $state("");
let newBoardVisibility = $state<"team" | "personal">("team");
let editBoardVisibility = $state<"team" | "personal">("team");
let targetColumnId = $state<string | null>(null); let targetColumnId = $state<string | null>(null);
let cardModalMode = $state<"edit" | "create">("edit"); let cardModalMode = $state<"edit" | "create">("edit");
@ -153,6 +155,23 @@
targetColumnId = null; targetColumnId = null;
} }
async function handleDeleteColumn(columnId: string) {
if (!selectedBoard) return;
if (!confirm("Delete this column and all its cards?")) return;
const { error } = await supabase
.from("kanban_columns")
.delete()
.eq("id", columnId);
if (!error) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.filter((c) => c.id !== columnId),
};
}
}
async function handleCardMove( async function handleCardMove(
cardId: string, cardId: string,
toColumnId: string, toColumnId: string,
@ -363,6 +382,7 @@
onAddCard={handleAddCard} onAddCard={handleAddCard}
onAddColumn={handleAddColumn} onAddColumn={handleAddColumn}
onDeleteCard={handleCardDelete} onDeleteCard={handleCardDelete}
onDeleteColumn={handleDeleteColumn}
/> />
{: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">

@ -7,6 +7,7 @@
extractCalendarId, extractCalendarId,
getCalendarSubscribeUrl, getCalendarSubscribeUrl,
} from "$lib/api/google-calendar"; } from "$lib/api/google-calendar";
import { theme, PRESET_COLORS, type ThemeMode } from "$lib/stores/theme";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types"; import type { Database } from "$lib/supabase/types";
@ -69,9 +70,9 @@
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
// Active tab // Active tab
let activeTab = $state<"general" | "members" | "roles" | "integrations">( let activeTab = $state<
"general", "general" | "members" | "roles" | "integrations" | "appearance"
); >("general");
// General settings state // General settings state
let orgName = $state(data.org.name); let orgName = $state(data.org.name);
@ -194,21 +195,37 @@
if (!inviteEmail.trim()) return; if (!inviteEmail.trim()) return;
isSendingInvite = true; isSendingInvite = true;
const email = inviteEmail.toLowerCase().trim();
// Delete any existing invite for this email first (handles 409 conflict)
await supabase
.from("org_invites")
.delete()
.eq("org_id", data.org.id)
.eq("email", email);
const { data: invite, error } = await supabase const { data: invite, error } = await supabase
.from("org_invites") .from("org_invites")
.insert({ .insert({
org_id: data.org.id, org_id: data.org.id,
email: inviteEmail.toLowerCase().trim(), email,
role: inviteRole, role: inviteRole,
invited_by: data.user!.id, invited_by: data.user!.id,
expires_at: new Date(
Date.now() + 7 * 24 * 60 * 60 * 1000,
).toISOString(),
}) })
.select() .select()
.single(); .single();
if (!error && invite) { if (!error && invite) {
// Remove old invite from UI if exists
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite]; invites = [...invites, invite as Invite];
inviteEmail = ""; inviteEmail = "";
showInviteModal = false; showInviteModal = false;
} else if (error) {
alert("Failed to send invite: " + error.message);
} }
isSendingInvite = false; isSendingInvite = false;
} }
@ -480,6 +497,13 @@
: 'text-light/50 hover:text-light'}" : 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "integrations")}>Integrations</button onclick={() => (activeTab = "integrations")}>Integrations</button
> >
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'appearance'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "appearance")}>Appearance</button
>
</div> </div>
<!-- General Tab --> <!-- General Tab -->
@ -953,6 +977,198 @@
</Card> </Card>
</div> </div>
{/if} {/if}
<!-- Appearance Tab -->
{#if activeTab === "appearance"}
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-4">Theme</h2>
<p class="text-sm text-light/50 mb-6">
Customize the look and feel of your workspace.
</p>
<!-- Mode Selector -->
<div class="mb-6">
<label class="block text-sm font-medium text-light mb-3"
>Mode</label
>
<div class="flex gap-2">
{#each ["dark", "light", "system"] as mode}
<button
class="flex-1 px-4 py-3 rounded-lg border transition-all {$theme.mode ===
mode
? 'border-primary bg-primary/10 text-primary'
: 'border-light/20 text-light/60 hover:border-light/40'}"
onclick={() =>
theme.setMode(mode as ThemeMode)}
>
<div
class="flex flex-col items-center gap-1"
>
{#if mode === "dark"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
/>
</svg>
{:else if mode === "light"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</svg>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
/>
<line
x1="8"
y1="21"
x2="16"
y2="21"
/>
<line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{/if}
<span class="text-xs capitalize"
>{mode}</span
>
</div>
</button>
{/each}
</div>
</div>
<!-- Accent Color -->
<div>
<label class="block text-sm font-medium text-light mb-3"
>Accent Color</label
>
<div class="grid grid-cols-4 gap-3">
{#each PRESET_COLORS as color}
<button
class="group relative h-12 rounded-lg transition-all {$theme.primaryColor ===
color.primary
? 'ring-2 ring-offset-2 ring-offset-dark ring-white'
: 'hover:scale-105'}"
style="background-color: {color.primary}"
onclick={() =>
theme.setPrimaryColor(color.primary)}
title={color.name}
>
{#if $theme.primaryColor === color.primary}
<svg
class="absolute inset-0 m-auto w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20,6 9,17 4,12" />
</svg>
{/if}
</button>
{/each}
</div>
<p class="text-xs text-light/40 mt-3">
Selected: {PRESET_COLORS.find(
(c) => c.primary === $theme.primaryColor,
)?.name || "Custom"}
</p>
</div>
</div>
</Card>
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-2">
Reset Theme
</h2>
<p class="text-sm text-light/50 mb-4">
Reset to the default theme settings.
</p>
<Button variant="secondary" onclick={() => theme.reset()}>
Reset to Default
</Button>
</div>
</Card>
</div>
{/if}
</div> </div>
<!-- Invite Member Modal --> <!-- Invite Member Modal -->

@ -0,0 +1,53 @@
-- Task comments for kanban cards
CREATE TABLE IF NOT EXISTS kanban_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
card_id UUID NOT NULL REFERENCES kanban_cards(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
ALTER TABLE kanban_comments ENABLE ROW LEVEL SECURITY;
-- Comments inherit access from the card's board -> org
CREATE POLICY "Comments inherit card access" ON kanban_comments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM kanban_cards c
JOIN kanban_columns col ON c.column_id = col.id
JOIN kanban_boards b ON col.board_id = b.id
JOIN org_members m ON b.org_id = m.org_id
WHERE c.id = kanban_comments.card_id
AND m.user_id = auth.uid()
)
);
-- Users can insert their own comments
CREATE POLICY "Users can insert own comments" ON kanban_comments
FOR INSERT WITH CHECK (
user_id = auth.uid() AND
EXISTS (
SELECT 1 FROM kanban_cards c
JOIN kanban_columns col ON c.column_id = col.id
JOIN kanban_boards b ON col.board_id = b.id
JOIN org_members m ON b.org_id = m.org_id
WHERE c.id = kanban_comments.card_id
AND m.user_id = auth.uid()
)
);
-- Users can update their own comments
CREATE POLICY "Users can update own comments" ON kanban_comments
FOR UPDATE USING (user_id = auth.uid());
-- Users can delete their own comments
CREATE POLICY "Users can delete own comments" ON kanban_comments
FOR DELETE USING (user_id = auth.uid());
-- Index for faster lookups
CREATE INDEX IF NOT EXISTS idx_kanban_comments_card ON kanban_comments(card_id);
CREATE INDEX IF NOT EXISTS idx_kanban_comments_user ON kanban_comments(user_id);
CREATE INDEX IF NOT EXISTS idx_kanban_comments_created ON kanban_comments(created_at DESC);
Loading…
Cancel
Save