Mega push vol 3
This commit is contained in:
@@ -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>
|
||||||
{#if column.color}
|
<div class="flex items-center gap-1">
|
||||||
<div
|
{#if column.color}
|
||||||
class="w-3 h-3 rounded-full"
|
<div
|
||||||
style="background-color: {column.color}"
|
class="w-3 h-3 rounded-full"
|
||||||
></div>
|
style="background-color: {column.color}"
|
||||||
{/if}
|
></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>
|
||||||
|
|
||||||
<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}
|
{#if card.due_date || (card as any).checklist_total > 0 || (card as any).assignee_id}
|
||||||
<div class="mt-2">
|
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<Badge
|
{#if card.due_date}
|
||||||
size="sm"
|
<Badge
|
||||||
variant={getDueDateColor(card.due_date)}
|
size="sm"
|
||||||
>
|
variant={getDueDateColor(card.due_date)}
|
||||||
{formatDueDate(card.due_date)}
|
>
|
||||||
</Badge>
|
{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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 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 -->
|
||||||
|
|||||||
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