Mega push vol 3
This commit is contained in:
@@ -13,6 +13,26 @@
|
||||
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 {
|
||||
card: KanbanCard | null;
|
||||
isOpen: boolean;
|
||||
@@ -23,6 +43,7 @@
|
||||
columnId?: string;
|
||||
userId?: string;
|
||||
onCreate?: (card: KanbanCard) => void;
|
||||
members?: Member[];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,6 +56,7 @@
|
||||
columnId,
|
||||
userId,
|
||||
onCreate,
|
||||
members = [],
|
||||
}: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
@@ -42,20 +64,38 @@
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let checklist = $state<ChecklistItem[]>([]);
|
||||
let comments = $state<Comment[]>([]);
|
||||
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 isSaving = $state(false);
|
||||
let showAssigneePicker = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === "edit" && card) {
|
||||
title = card.title;
|
||||
description = card.description ?? "";
|
||||
assigneeId = (card as any).assignee_id ?? null;
|
||||
dueDate = (card as any).due_date
|
||||
? new Date((card as any).due_date)
|
||||
.toISOString()
|
||||
.split("T")[0]
|
||||
: "";
|
||||
priority = (card as any).priority ?? "medium";
|
||||
loadChecklist();
|
||||
loadComments();
|
||||
} else if (mode === "create") {
|
||||
title = "";
|
||||
description = "";
|
||||
assigneeId = null;
|
||||
dueDate = "";
|
||||
priority = "medium";
|
||||
checklist = [];
|
||||
comments = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -65,7 +105,7 @@
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await supabase
|
||||
.from("checklist_items")
|
||||
.from("kanban_checklist_items")
|
||||
.select("*")
|
||||
.eq("card_id", card.id)
|
||||
.order("position");
|
||||
@@ -74,6 +114,27 @@
|
||||
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() {
|
||||
if (mode === "create") {
|
||||
await handleCreate();
|
||||
@@ -88,11 +149,21 @@
|
||||
.update({
|
||||
title,
|
||||
description: description || null,
|
||||
assignee_id: assigneeId,
|
||||
due_date: dueDate || null,
|
||||
priority,
|
||||
})
|
||||
.eq("id", card.id);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -133,7 +204,7 @@
|
||||
|
||||
const position = checklist.length;
|
||||
const { data, error } = await supabase
|
||||
.from("checklist_items")
|
||||
.from("kanban_checklist_items")
|
||||
.insert({
|
||||
card_id: card.id,
|
||||
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) {
|
||||
const { error } = await supabase
|
||||
.from("checklist_items")
|
||||
.from("kanban_checklist_items")
|
||||
.update({ completed: !item.completed })
|
||||
.eq("id", item.id);
|
||||
|
||||
@@ -164,7 +263,7 @@
|
||||
|
||||
async function deleteItem(itemId: string) {
|
||||
const { error } = await supabase
|
||||
.from("checklist_items")
|
||||
.from("kanban_checklist_items")
|
||||
.delete()
|
||||
.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() {
|
||||
if (!card || !confirm("Delete this card?")) return;
|
||||
|
||||
@@ -207,6 +320,129 @@
|
||||
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 class="flex items-center justify-between mb-3">
|
||||
<label class="text-sm font-medium text-light"
|
||||
@@ -302,6 +538,71 @@
|
||||
{/if}
|
||||
</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
|
||||
class="flex items-center justify-between pt-3 border-t border-light/10"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
onAddCard?: (columnId: string) => void;
|
||||
onAddColumn?: () => void;
|
||||
onDeleteCard?: (cardId: string) => void;
|
||||
onDeleteColumn?: (columnId: string) => void;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@
|
||||
onAddCard,
|
||||
onAddColumn,
|
||||
onDeleteCard,
|
||||
onDeleteColumn,
|
||||
canEdit = true,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -115,12 +117,34 @@
|
||||
{column.cards.length}
|
||||
</span>
|
||||
</h3>
|
||||
{#if column.color}
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {column.color}"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1">
|
||||
{#if column.color}
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {column.color}"
|
||||
></div>
|
||||
{/if}
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
|
||||
onclick={() => onDeleteColumn?.(column.id)}
|
||||
title="Delete column"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3,6 5,6 21,6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
@@ -168,14 +192,63 @@
|
||||
{card.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if card.due_date}
|
||||
<div class="mt-2">
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={getDueDateColor(card.due_date)}
|
||||
>
|
||||
{formatDueDate(card.due_date)}
|
||||
</Badge>
|
||||
{#if card.due_date || (card as any).checklist_total > 0 || (card as any).assignee_id}
|
||||
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||
{#if card.due_date}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={getDueDateColor(card.due_date)}
|
||||
>
|
||||
{formatDueDate(card.due_date)}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if (card as any).checklist_total > 0}
|
||||
<span
|
||||
class="text-xs flex items-center gap-1 {(
|
||||
card as any
|
||||
).checklist_done ===
|
||||
(card as any).checklist_total
|
||||
? 'text-success'
|
||||
: 'text-light/50'}"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline
|
||||
points="9,11 12,14 22,4"
|
||||
/>
|
||||
<path
|
||||
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
|
||||
/>
|
||||
</svg>
|
||||
{(card as any).checklist_done}/{(
|
||||
card as any
|
||||
).checklist_total}
|
||||
</span>
|
||||
{/if}
|
||||
{#if (card as any).assignee_id}
|
||||
<div
|
||||
class="w-5 h-5 rounded-full bg-primary/30 flex items-center justify-center text-[10px] text-primary ml-auto"
|
||||
title="Assigned"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||
/>
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
222
src/lib/stores/theme.ts
Normal file
222
src/lib/stores/theme.ts
Normal file
@@ -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 newBoardName = $state("");
|
||||
let editBoardName = $state("");
|
||||
let newBoardVisibility = $state<"team" | "personal">("team");
|
||||
let editBoardVisibility = $state<"team" | "personal">("team");
|
||||
let targetColumnId = $state<string | null>(null);
|
||||
let cardModalMode = $state<"edit" | "create">("edit");
|
||||
|
||||
@@ -153,6 +155,23 @@
|
||||
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(
|
||||
cardId: string,
|
||||
toColumnId: string,
|
||||
@@ -363,6 +382,7 @@
|
||||
onAddCard={handleAddCard}
|
||||
onAddColumn={handleAddColumn}
|
||||
onDeleteCard={handleCardDelete}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
extractCalendarId,
|
||||
getCalendarSubscribeUrl,
|
||||
} from "$lib/api/google-calendar";
|
||||
import { theme, PRESET_COLORS, type ThemeMode } from "$lib/stores/theme";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
@@ -69,9 +70,9 @@
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
// Active tab
|
||||
let activeTab = $state<"general" | "members" | "roles" | "integrations">(
|
||||
"general",
|
||||
);
|
||||
let activeTab = $state<
|
||||
"general" | "members" | "roles" | "integrations" | "appearance"
|
||||
>("general");
|
||||
|
||||
// General settings state
|
||||
let orgName = $state(data.org.name);
|
||||
@@ -194,21 +195,37 @@
|
||||
if (!inviteEmail.trim()) return;
|
||||
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
|
||||
.from("org_invites")
|
||||
.insert({
|
||||
org_id: data.org.id,
|
||||
email: inviteEmail.toLowerCase().trim(),
|
||||
email,
|
||||
role: inviteRole,
|
||||
invited_by: data.user!.id,
|
||||
expires_at: new Date(
|
||||
Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && invite) {
|
||||
// Remove old invite from UI if exists
|
||||
invites = invites.filter((i) => i.email !== email);
|
||||
invites = [...invites, invite as Invite];
|
||||
inviteEmail = "";
|
||||
showInviteModal = false;
|
||||
} else if (error) {
|
||||
alert("Failed to send invite: " + error.message);
|
||||
}
|
||||
isSendingInvite = false;
|
||||
}
|
||||
@@ -480,6 +497,13 @@
|
||||
: 'text-light/50 hover:text-light'}"
|
||||
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>
|
||||
|
||||
<!-- General Tab -->
|
||||
@@ -953,6 +977,198 @@
|
||||
</Card>
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
<!-- Invite Member Modal -->
|
||||
|
||||
53
supabase/migrations/012_task_comments.sql
Normal file
53
supabase/migrations/012_task_comments.sql
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user