875 lines
21 KiB
Svelte
875 lines
21 KiB
Svelte
<script lang="ts">
|
|
import { getContext, onDestroy } from "svelte";
|
|
import {
|
|
Modal,
|
|
Button,
|
|
Input,
|
|
Textarea,
|
|
Select,
|
|
AssigneePicker,
|
|
Icon,
|
|
} from "$lib/components/ui";
|
|
import type { KanbanCard } from "$lib/supabase/types";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { Database } from "$lib/supabase/types";
|
|
|
|
let isMounted = $state(true);
|
|
onDestroy(() => {
|
|
isMounted = false;
|
|
});
|
|
|
|
interface ChecklistItem {
|
|
id: string;
|
|
card_id: string;
|
|
title: string;
|
|
completed: boolean;
|
|
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 OrgTag {
|
|
id: string;
|
|
name: string;
|
|
color: string | null;
|
|
}
|
|
|
|
interface Props {
|
|
card: KanbanCard | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onUpdate: (card: KanbanCard) => void;
|
|
onDelete: (cardId: string) => void;
|
|
mode?: "edit" | "create";
|
|
columnId?: string;
|
|
userId?: string;
|
|
orgId?: string;
|
|
onCreate?: (card: KanbanCard) => void;
|
|
members?: Member[];
|
|
}
|
|
|
|
let {
|
|
card,
|
|
isOpen,
|
|
onClose,
|
|
onUpdate,
|
|
onDelete,
|
|
mode = "edit",
|
|
columnId,
|
|
userId,
|
|
orgId,
|
|
onCreate,
|
|
members = [],
|
|
}: Props = $props();
|
|
|
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
|
|
|
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);
|
|
|
|
// Tags state
|
|
let orgTags = $state<OrgTag[]>([]);
|
|
let cardTagIds = $state<Set<string>>(new Set());
|
|
let newTagName = $state("");
|
|
let showTagInput = $state(false);
|
|
let editingTagId = $state<string | null>(null);
|
|
let editTagName = $state("");
|
|
let editTagColor = $state("");
|
|
let showTagManager = $state(false);
|
|
|
|
const TAG_COLORS = [
|
|
"#00A3E0",
|
|
"#33E000",
|
|
"#E03D00",
|
|
"#FFAB00",
|
|
"#A855F7",
|
|
"#EC4899",
|
|
"#6366F1",
|
|
];
|
|
|
|
$effect(() => {
|
|
if (isOpen) {
|
|
if (mode === "edit" && card) {
|
|
title = card.title;
|
|
description = card.description ?? "";
|
|
assigneeId = card.assignee_id ?? null;
|
|
dueDate = card.due_date
|
|
? new Date(card.due_date).toISOString().split("T")[0]
|
|
: "";
|
|
priority = card.priority ?? "medium";
|
|
loadChecklist();
|
|
loadComments();
|
|
loadTags();
|
|
} else if (mode === "create") {
|
|
title = "";
|
|
description = "";
|
|
assigneeId = null;
|
|
dueDate = "";
|
|
priority = "medium";
|
|
checklist = [];
|
|
comments = [];
|
|
cardTagIds = new Set();
|
|
loadOrgTags();
|
|
}
|
|
}
|
|
});
|
|
|
|
async function loadChecklist() {
|
|
if (!card || !isMounted) return;
|
|
isLoading = true;
|
|
|
|
const { data } = await supabase
|
|
.from("kanban_checklist_items")
|
|
.select("*")
|
|
.eq("card_id", card.id)
|
|
.order("position");
|
|
|
|
if (!isMounted) return;
|
|
checklist = (data ?? []) as ChecklistItem[];
|
|
isLoading = false;
|
|
}
|
|
|
|
async function loadComments() {
|
|
if (!card || !isMounted) 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 });
|
|
|
|
if (!isMounted) return;
|
|
comments = (data ?? []) as Comment[];
|
|
}
|
|
|
|
async function loadOrgTags() {
|
|
if (!orgId) return;
|
|
const { data } = await supabase
|
|
.from("tags")
|
|
.select("id, name, color")
|
|
.eq("org_id", orgId)
|
|
.order("name");
|
|
if (!isMounted) return;
|
|
orgTags = (data ?? []) as OrgTag[];
|
|
}
|
|
|
|
async function loadTags() {
|
|
await loadOrgTags();
|
|
if (!card) return;
|
|
const { data } = await supabase
|
|
.from("card_tags")
|
|
.select("tag_id")
|
|
.eq("card_id", card.id);
|
|
if (!isMounted) return;
|
|
cardTagIds = new Set((data ?? []).map((t) => t.tag_id));
|
|
}
|
|
|
|
async function toggleTag(tagId: string) {
|
|
if (!card) return;
|
|
if (cardTagIds.has(tagId)) {
|
|
await supabase
|
|
.from("card_tags")
|
|
.delete()
|
|
.eq("card_id", card.id)
|
|
.eq("tag_id", tagId);
|
|
cardTagIds.delete(tagId);
|
|
cardTagIds = new Set(cardTagIds);
|
|
} else {
|
|
await supabase
|
|
.from("card_tags")
|
|
.insert({ card_id: card.id, tag_id: tagId });
|
|
cardTagIds.add(tagId);
|
|
cardTagIds = new Set(cardTagIds);
|
|
}
|
|
}
|
|
|
|
async function createTag() {
|
|
if (!newTagName.trim() || !orgId) return;
|
|
const color = TAG_COLORS[orgTags.length % TAG_COLORS.length];
|
|
const { data: newTag, error } = await supabase
|
|
.from("tags")
|
|
.insert({ name: newTagName.trim(), org_id: orgId, color })
|
|
.select()
|
|
.single();
|
|
if (!error && newTag) {
|
|
orgTags = [...orgTags, newTag as OrgTag];
|
|
if (card) {
|
|
await supabase
|
|
.from("card_tags")
|
|
.insert({ card_id: card.id, tag_id: newTag.id });
|
|
cardTagIds.add(newTag.id);
|
|
cardTagIds = new Set(cardTagIds);
|
|
}
|
|
}
|
|
newTagName = "";
|
|
showTagInput = false;
|
|
}
|
|
|
|
function startEditTag(tag: OrgTag) {
|
|
editingTagId = tag.id;
|
|
editTagName = tag.name;
|
|
editTagColor = tag.color || TAG_COLORS[0];
|
|
}
|
|
|
|
async function saveEditTag() {
|
|
if (!editingTagId || !editTagName.trim()) return;
|
|
const { error } = await supabase
|
|
.from("tags")
|
|
.update({ name: editTagName.trim(), color: editTagColor })
|
|
.eq("id", editingTagId);
|
|
if (!error) {
|
|
orgTags = orgTags.map((t) =>
|
|
t.id === editingTagId
|
|
? { ...t, name: editTagName.trim(), color: editTagColor }
|
|
: t,
|
|
);
|
|
}
|
|
editingTagId = null;
|
|
}
|
|
|
|
async function deleteTag(tagId: string) {
|
|
if (!confirm("Delete this tag from the organization?")) return;
|
|
const { error } = await supabase.from("tags").delete().eq("id", tagId);
|
|
if (!error) {
|
|
orgTags = orgTags.filter((t) => t.id !== tagId);
|
|
cardTagIds.delete(tagId);
|
|
cardTagIds = new Set(cardTagIds);
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!isMounted) return;
|
|
if (mode === "create") {
|
|
await handleCreate();
|
|
return;
|
|
}
|
|
|
|
if (!card) return;
|
|
isSaving = true;
|
|
|
|
const { error } = await supabase
|
|
.from("kanban_cards")
|
|
.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,
|
|
assignee_id: assigneeId,
|
|
due_date: dueDate || null,
|
|
priority,
|
|
} as KanbanCard);
|
|
}
|
|
isSaving = false;
|
|
}
|
|
|
|
async function handleCreate() {
|
|
if (!title.trim() || !columnId || !userId) return;
|
|
isSaving = true;
|
|
|
|
const { data: column } = await supabase
|
|
.from("kanban_columns")
|
|
.select("cards:kanban_cards(count)")
|
|
.eq("id", columnId)
|
|
.single();
|
|
|
|
const cards = (column as Record<string, unknown> | null)?.cards as
|
|
| { count: number }[]
|
|
| undefined;
|
|
const position = cards?.[0]?.count ?? 0;
|
|
|
|
const { data: newCard, error } = await supabase
|
|
.from("kanban_cards")
|
|
.insert({
|
|
column_id: columnId,
|
|
title,
|
|
description: description || null,
|
|
priority: priority || null,
|
|
due_date: dueDate || null,
|
|
assignee_id: assigneeId || null,
|
|
position,
|
|
created_by: userId,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (!error && newCard) {
|
|
// Persist checklist items added during creation
|
|
if (checklist.length > 0) {
|
|
await supabase.from("kanban_checklist_items").insert(
|
|
checklist.map((item, i) => ({
|
|
card_id: newCard.id,
|
|
title: item.title,
|
|
position: i,
|
|
completed: false,
|
|
})),
|
|
);
|
|
}
|
|
// Persist tags assigned during creation
|
|
if (cardTagIds.size > 0) {
|
|
await supabase.from("card_tags").insert(
|
|
[...cardTagIds].map((tagId) => ({
|
|
card_id: newCard.id,
|
|
tag_id: tagId,
|
|
})),
|
|
);
|
|
}
|
|
onCreate?.(newCard as KanbanCard);
|
|
onClose();
|
|
}
|
|
isSaving = false;
|
|
}
|
|
|
|
async function handleAddItem() {
|
|
if (!newItemTitle.trim()) return;
|
|
|
|
if (mode === "create") {
|
|
// In create mode, add items locally (no card ID yet)
|
|
checklist = [
|
|
...checklist,
|
|
{
|
|
id: `temp-${Date.now()}`,
|
|
card_id: "",
|
|
title: newItemTitle.trim(),
|
|
completed: false,
|
|
position: checklist.length,
|
|
},
|
|
];
|
|
newItemTitle = "";
|
|
return;
|
|
}
|
|
|
|
if (!card) return;
|
|
|
|
const position = checklist.length;
|
|
const { data, error } = await supabase
|
|
.from("kanban_checklist_items")
|
|
.insert({
|
|
card_id: card.id,
|
|
title: newItemTitle,
|
|
position,
|
|
completed: false,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (!error && data) {
|
|
checklist = [...checklist, data as ChecklistItem];
|
|
newItemTitle = "";
|
|
}
|
|
}
|
|
|
|
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("kanban_checklist_items")
|
|
.update({ completed: !item.completed })
|
|
.eq("id", item.id);
|
|
|
|
if (!error) {
|
|
checklist = checklist.map((i) =>
|
|
i.id === item.id ? { ...i, completed: !i.completed } : i,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function deleteItem(itemId: string) {
|
|
const { error } = await supabase
|
|
.from("kanban_checklist_items")
|
|
.delete()
|
|
.eq("id", itemId);
|
|
|
|
if (!error) {
|
|
checklist = checklist.filter((i) => i.id !== itemId);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
await supabase.from("kanban_cards").delete().eq("id", card.id);
|
|
|
|
onDelete(card.id);
|
|
onClose();
|
|
}
|
|
|
|
const completedCount = $derived(
|
|
checklist.filter((i) => i.completed).length,
|
|
);
|
|
const progress = $derived(
|
|
checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0,
|
|
);
|
|
</script>
|
|
|
|
<Modal
|
|
{isOpen}
|
|
{onClose}
|
|
title={mode === "create" ? "Add Card" : "Card Details"}
|
|
size="lg"
|
|
>
|
|
{#if mode === "create" || card}
|
|
<div class="space-y-5">
|
|
<Input label="Title" bind:value={title} placeholder="Card title" />
|
|
|
|
<Textarea
|
|
label="Description"
|
|
bind:value={description}
|
|
placeholder="Add a more detailed description..."
|
|
rows={3}
|
|
/>
|
|
|
|
<!-- Tags -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="px-3 font-bold font-body text-body text-white"
|
|
>Tags</span
|
|
>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-light/40 hover:text-light transition-colors"
|
|
onclick={() => (showTagManager = !showTagManager)}
|
|
>
|
|
{showTagManager ? "Done" : "Manage"}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showTagManager}
|
|
<!-- Tag Manager: edit/delete/create tags -->
|
|
<div class="space-y-2 mb-3 p-3 bg-background rounded-2xl">
|
|
{#each orgTags as tag}
|
|
<div class="flex items-center gap-2 group">
|
|
{#if editingTagId === tag.id}
|
|
<div class="flex items-center gap-2 flex-1">
|
|
<input
|
|
type="color"
|
|
class="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
|
|
bind:value={editTagColor}
|
|
/>
|
|
<input
|
|
type="text"
|
|
class="bg-dark border border-primary rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none"
|
|
bind:value={editTagName}
|
|
onkeydown={(e) => {
|
|
if (e.key === "Enter")
|
|
saveEditTag();
|
|
if (e.key === "Escape") {
|
|
editingTagId = null;
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="text-primary text-xs font-bold"
|
|
onclick={saveEditTag}>Save</button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="text-light/40 text-xs"
|
|
onclick={() =>
|
|
(editingTagId = null)}
|
|
>Cancel</button
|
|
>
|
|
</div>
|
|
{:else}
|
|
<span
|
|
class="w-3 h-3 rounded-sm shrink-0"
|
|
style="background-color: {tag.color ||
|
|
'#00A3E0'}"
|
|
></span>
|
|
<span
|
|
class="text-sm text-light flex-1 truncate"
|
|
>{tag.name}</span
|
|
>
|
|
<button
|
|
type="button"
|
|
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-light transition-all"
|
|
onclick={() => startEditTag(tag)}
|
|
aria-label="Edit tag"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
|
>edit</span
|
|
>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all"
|
|
onclick={() => deleteTag(tag.id)}
|
|
aria-label="Delete tag"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
|
>delete</span
|
|
>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
<!-- Inline create new tag -->
|
|
<div
|
|
class="flex items-center gap-2 pt-1 border-t border-light/10"
|
|
>
|
|
<input
|
|
type="text"
|
|
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none focus:border-primary"
|
|
placeholder="New tag name..."
|
|
bind:value={newTagName}
|
|
onkeydown={(e) =>
|
|
e.key === "Enter" && createTag()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="text-primary text-xs font-bold hover:text-primary/80 whitespace-nowrap"
|
|
onclick={createTag}
|
|
disabled={!newTagName.trim()}
|
|
>
|
|
+ Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Tag toggle chips -->
|
|
<div class="flex flex-wrap gap-2 items-center">
|
|
{#each orgTags as tag}
|
|
<button
|
|
type="button"
|
|
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2"
|
|
style="background-color: {cardTagIds.has(tag.id)
|
|
? tag.color || '#00A3E0'
|
|
: 'transparent'}; color: {cardTagIds.has(tag.id)
|
|
? '#0A121F'
|
|
: tag.color ||
|
|
'#00A3E0'}; border-color: {tag.color ||
|
|
'#00A3E0'};"
|
|
onclick={() => toggleTag(tag.id)}
|
|
>
|
|
{tag.name}
|
|
</button>
|
|
{/each}
|
|
{#if !showTagManager}
|
|
{#if showTagInput}
|
|
<div class="flex gap-1 items-center">
|
|
<input
|
|
type="text"
|
|
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
|
|
placeholder="Tag name"
|
|
bind:value={newTagName}
|
|
onkeydown={(e) =>
|
|
e.key === "Enter" && createTag()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="text-primary text-sm font-bold hover:text-primary/80"
|
|
onclick={createTag}
|
|
>
|
|
Add
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-light/40 text-sm hover:text-light"
|
|
onclick={() => {
|
|
showTagInput = false;
|
|
newTagName = "";
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
|
|
onclick={() => (showTagInput = true)}
|
|
>
|
|
+ New tag
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Assignee, Due Date, Priority Row -->
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<AssigneePicker
|
|
label="Assignee"
|
|
value={assigneeId}
|
|
{members}
|
|
onchange={(id) => (assigneeId = id)}
|
|
/>
|
|
|
|
<Input type="date" label="Due Date" bind:value={dueDate} />
|
|
|
|
<Select
|
|
label="Priority"
|
|
bind:value={priority}
|
|
placeholder=""
|
|
options={[
|
|
{ value: "low", label: "Low" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "high", label: "High" },
|
|
{ value: "urgent", label: "Urgent" },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="px-3 font-bold font-body text-body text-white"
|
|
>Checklist</span
|
|
>
|
|
{#if checklist.length > 0}
|
|
<span class="text-xs text-light/50"
|
|
>{completedCount}/{checklist.length}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if checklist.length > 0}
|
|
<div
|
|
class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden"
|
|
>
|
|
<div
|
|
class="h-full bg-success transition-all duration-300"
|
|
style="width: {progress}%"
|
|
></div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if isLoading}
|
|
<div class="text-light/50 text-sm py-2">Loading...</div>
|
|
{:else}
|
|
<div class="space-y-2 mb-3">
|
|
{#each checklist as item}
|
|
<div class="flex items-center gap-3 group">
|
|
<button
|
|
class="w-5 h-5 rounded border flex items-center justify-center transition-colors
|
|
{item.completed
|
|
? 'bg-success border-success'
|
|
: 'border-light/30 hover:border-light/50'}"
|
|
onclick={() => toggleItem(item)}
|
|
>
|
|
{#if item.completed}
|
|
<svg
|
|
class="w-3 h-3 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>
|
|
<span
|
|
class="flex-1 text-sm {item.completed
|
|
? 'line-through text-light/40'
|
|
: 'text-light'}"
|
|
>
|
|
{item.title}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
|
|
onclick={() => deleteItem(item.id)}
|
|
aria-label="Delete item"
|
|
>
|
|
<Icon name="close" size={16} />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="flex gap-2 items-end">
|
|
<Input
|
|
placeholder="Add an item..."
|
|
bind:value={newItemTitle}
|
|
onkeydown={(e) =>
|
|
e.key === "Enter" && handleAddItem()}
|
|
/>
|
|
<Button
|
|
size="md"
|
|
onclick={handleAddItem}
|
|
disabled={!newItemTitle.trim()}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Comments Section -->
|
|
{#if mode === "edit"}
|
|
<div>
|
|
<span
|
|
class="px-3 font-bold font-body text-body text-white mb-3 block"
|
|
>Comments</span
|
|
>
|
|
<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?.full_name ||
|
|
comment.profiles?.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?.full_name ||
|
|
comment.profiles?.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 items-end">
|
|
<Input
|
|
placeholder="Add a comment..."
|
|
bind:value={newComment}
|
|
onkeydown={(e) =>
|
|
e.key === "Enter" && handleAddComment()}
|
|
/>
|
|
<Button
|
|
size="md"
|
|
onclick={handleAddComment}
|
|
disabled={!newComment.trim()}
|
|
>
|
|
Send
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="flex items-center justify-between pt-3 border-t border-light/10"
|
|
>
|
|
{#if mode === "edit"}
|
|
<Button variant="danger" onclick={handleDelete}>
|
|
Delete Card
|
|
</Button>
|
|
{:else}
|
|
<div></div>
|
|
{/if}
|
|
<div class="flex gap-2">
|
|
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
|
|
<Button
|
|
onclick={handleSave}
|
|
loading={isSaving}
|
|
disabled={!title.trim()}
|
|
>
|
|
{mode === "create" ? "Add Card" : "Save Changes"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</Modal>
|