Mega push vol 4

This commit is contained in:
AlacrisDevs
2026-02-06 16:08:40 +02:00
parent b517bb975c
commit d8bbfd9dc3
95 changed files with 8019 additions and 3946 deletions

View File

@@ -1,10 +1,23 @@
<script lang="ts">
import { getContext } from "svelte";
import { Modal, Button, Input, Textarea } from "$lib/components/ui";
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;
@@ -33,6 +46,12 @@
};
}
interface OrgTag {
id: string;
name: string;
color: string | null;
}
interface Props {
card: KanbanCard | null;
isOpen: boolean;
@@ -42,6 +61,7 @@
mode?: "edit" | "create";
columnId?: string;
userId?: string;
orgId?: string;
onCreate?: (card: KanbanCard) => void;
members?: Member[];
}
@@ -55,6 +75,7 @@
mode = "edit",
columnId,
userId,
orgId,
onCreate,
members = [],
}: Props = $props();
@@ -74,20 +95,35 @@
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);
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 as any).assignee_id ?? null;
dueDate = (card as any).due_date
? new Date((card as any).due_date)
.toISOString()
.split("T")[0]
assigneeId = card.assignee_id ?? null;
dueDate = card.due_date
? new Date(card.due_date).toISOString().split("T")[0]
: "";
priority = (card as any).priority ?? "medium";
priority = card.priority ?? "medium";
loadChecklist();
loadComments();
loadTags();
} else if (mode === "create") {
title = "";
description = "";
@@ -96,12 +132,14 @@
priority = "medium";
checklist = [];
comments = [];
cardTagIds = new Set();
loadOrgTags();
}
}
});
async function loadChecklist() {
if (!card) return;
if (!card || !isMounted) return;
isLoading = true;
const { data } = await supabase
@@ -110,12 +148,13 @@
.eq("card_id", card.id)
.order("position");
if (!isMounted) return;
checklist = (data ?? []) as ChecklistItem[];
isLoading = false;
}
async function loadComments() {
if (!card) return;
if (!card || !isMounted) return;
const { data } = await supabase
.from("kanban_comments")
@@ -132,10 +171,75 @@
.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;
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
await handleCreate();
return;
@@ -178,7 +282,7 @@
.eq("id", columnId)
.single();
const position = (column as any)?.cards?.[0]?.count ?? 0;
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
const { data: newCard, error } = await supabase
.from("kanban_cards")
@@ -186,6 +290,9 @@
column_id: columnId,
title,
description: description || null,
priority: priority || null,
due_date: dueDate || null,
assignee_id: assigneeId || null,
position,
created_by: userId,
})
@@ -320,133 +427,97 @@
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"
<!-- Tags -->
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-2 block"
>Tags</span
>
<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 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
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
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={() => {
assigneeId = null;
showAssigneePicker = false;
showTagInput = false;
newTagName = "";
}}
>
<div
class="w-6 h-6 rounded-full bg-light/10"
></div>
Unassigned
Cancel
</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>
{: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}
</div>
</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>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
/>
<!-- 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>
<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">
<label class="text-sm font-medium text-light"
>Checklist</label
<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"
@@ -499,36 +570,26 @@
{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"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<Icon name="close" size={16} />
</button>
</div>
{/each}
</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"
<div class="flex gap-2 items-end">
<Input
placeholder="Add an item..."
bind:value={newItemTitle}
onkeydown={(e) =>
e.key === "Enter" && handleAddItem()}
/>
<Button
size="sm"
size="md"
onclick={handleAddItem}
disabled={!newItemTitle.trim()}
>
@@ -541,8 +602,9 @@
<!-- Comments Section -->
{#if mode === "edit"}
<div>
<label class="block text-sm font-medium text-light mb-3"
>Comments</label
<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}
@@ -550,8 +612,8 @@
<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 ||
{(comment.profiles?.full_name ||
comment.profiles?.email ||
"?")[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
@@ -559,10 +621,8 @@
<span
class="text-sm font-medium text-light"
>
{(comment.profiles as any)
?.full_name ||
(comment.profiles as any)
?.email ||
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40"
@@ -583,17 +643,15 @@
</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"
<div class="flex gap-2 items-end">
<Input
placeholder="Add a comment..."
bind:value={newComment}
onkeydown={(e) =>
e.key === "Enter" && handleAddComment()}
/>
<Button
size="sm"
size="md"
onclick={handleAddComment}
disabled={!newComment.trim()}
>
@@ -614,7 +672,7 @@
<div></div>
{/if}
<div class="flex gap-2">
<Button variant="ghost" onclick={onClose}>Cancel</Button>
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
<Button
onclick={handleSave}
loading={isSaving}