Mega push vol 6, started adding many awesome stuff, chat broken rn

This commit is contained in:
AlacrisDevs
2026-02-07 15:11:28 +02:00
parent f9dc950394
commit dcee479839
19 changed files with 1634 additions and 209 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from "$app/stores";
import { page, navigating } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
import { getContext } from "svelte";
@@ -147,8 +147,15 @@
]);
function isActive(href: string): boolean {
const navTo = $navigating?.to?.url.pathname;
if (navTo) return navTo.startsWith(href);
return $page.url.pathname.startsWith(href);
}
function isNavigatingTo(href: string): boolean {
const navTo = $navigating?.to?.url.pathname;
return !!navTo && navTo.startsWith(href) && !$page.url.pathname.startsWith(href);
}
</script>
<!-- Figma-matched layout: bg-background with gap-4 padding -->
@@ -230,7 +237,11 @@
? 'opacity-0 max-w-0 overflow-hidden'
: 'opacity-100 max-w-[200px]'}">{item.label}</span
>
{#if item.badge}
{#if isNavigatingTo(item.href)}
<span class="ml-auto shrink-0 {sidebarCollapsed ? 'hidden' : ''}">
<span class="block w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin"></span>
</span>
{:else if item.badge}
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
{item.badge}
</span>
@@ -337,9 +348,7 @@
class="flex items-center justify-center"
>
<Logo
size={sidebarCollapsed ? "sm" : "md"}
showText={!sidebarCollapsed}
/>
size={sidebarCollapsed ? "sm" : "md"} />
</a>
</div>
</aside>

View File

@@ -2,7 +2,8 @@
import { onMount, getContext } from "svelte";
import { browser } from "$app/environment";
import { page } from "$app/state";
import { Avatar, Button, Input, Modal } from "$lib/components/ui";
import { Avatar, Button, Modal } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
import {
MessageList,
MessageInput,
@@ -51,13 +52,8 @@
// Matrix state
let matrixClient = $state<MatrixClient | null>(null);
let isInitializing = $state(true);
let showMatrixLogin = $state(false);
// Matrix login form
let matrixHomeserver = $state("https://matrix.org");
let matrixUsername = $state("");
let matrixPassword = $state("");
let isLoggingIn = $state(false);
let showJoinScreen = $state(false);
let isProvisioning = $state(false);
// Chat UI state
let showCreateRoomModal = $state(false);
@@ -140,13 +136,13 @@
deviceId: result.credentials.device_id,
});
} else {
// No stored credentials — show login form
showMatrixLogin = true;
// No stored credentials — show join screen
showJoinScreen = true;
isInitializing = false;
}
} catch (e) {
console.error("Failed to load Matrix credentials:", e);
showMatrixLogin = true;
showJoinScreen = true;
isInitializing = false;
}
});
@@ -169,8 +165,8 @@
await ensureOrgSpace(credentials);
} catch (e: unknown) {
console.error("Failed to init Matrix client:", e);
toasts.error("Failed to connect to chat. Please re-login.");
showMatrixLogin = true;
toasts.error(m.chat_join_error());
showJoinScreen = true;
} finally {
isInitializing = false;
}
@@ -204,41 +200,33 @@
}
}
async function handleMatrixLogin() {
if (!matrixUsername.trim() || !matrixPassword.trim()) {
toasts.error("Please enter username and password");
return;
}
isLoggingIn = true;
async function handleJoinChat() {
isProvisioning = true;
try {
const { loginWithPassword } = await import("$lib/matrix");
const credentials = await loginWithPassword({
homeserverUrl: matrixHomeserver,
username: matrixUsername.trim(),
password: matrixPassword,
});
// Save to Supabase
await fetch("/api/matrix-credentials", {
const res = await fetch("/api/matrix-provision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
homeserver_url: credentials.homeserverUrl,
matrix_user_id: credentials.userId,
access_token: credentials.accessToken,
device_id: credentials.deviceId,
}),
body: JSON.stringify({ org_id: data.org.id }),
});
showMatrixLogin = false;
await initFromCredentials(credentials);
toasts.success("Connected to chat!");
const result = await res.json();
if (!res.ok) throw new Error(result.error || "Provisioning failed");
showJoinScreen = false;
await initFromCredentials({
homeserverUrl: result.credentials.homeserver_url,
userId: result.credentials.matrix_user_id,
accessToken: result.credentials.access_token,
deviceId: result.credentials.device_id,
});
if (result.provisioned) {
toasts.success(m.chat_join_success());
}
} catch (e: any) {
toasts.error(e.message || "Login failed");
toasts.error(e.message || m.chat_join_error());
} finally {
isLoggingIn = false;
isProvisioning = false;
}
}
@@ -255,7 +243,7 @@
});
matrixClient = null;
showMatrixLogin = true;
showJoinScreen = true;
auth.set({
isLoggedIn: false,
userId: null,
@@ -389,47 +377,37 @@
}
</script>
<!-- Matrix Login Modal -->
{#if showMatrixLogin}
<!-- Join Chat consent screen -->
{#if showJoinScreen}
<div class="h-full flex items-center justify-center">
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md">
<h2 class="font-heading text-body text-white mb-1">Connect to Chat</h2>
<p class="text-body-sm text-light/50 mb-6">
Enter your Matrix credentials to enable messaging.
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md text-center">
<span
class="material-symbols-rounded text-primary mb-4 inline-block"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>chat</span>
<h2 class="font-heading text-h3 text-white mb-2">{m.chat_join_title()}</h2>
<p class="text-body-sm text-light/50 mb-4">
{m.chat_join_description()}
</p>
<div class="space-y-4">
<Input
label="Homeserver URL"
bind:value={matrixHomeserver}
placeholder="https://matrix.org"
/>
<Input
label="Username"
bind:value={matrixUsername}
placeholder="@user:matrix.org"
/>
<div>
<label class="block text-body-sm font-body text-light/60 mb-1">Password</label>
<input
type="password"
bind:value={matrixPassword}
placeholder="Password"
class="w-full bg-dark border border-light/10 rounded-xl px-3 py-2 text-white font-body text-body-sm placeholder:text-light/30 focus:outline-none focus:border-primary"
onkeydown={(e) => {
if (e.key === "Enter") handleMatrixLogin();
}}
/>
</div>
<Button
variant="primary"
fullWidth
onclick={handleMatrixLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? "Connecting..." : "Connect"}
</Button>
</div>
<p class="text-body-sm text-light/40 mb-6">
{m.chat_join_consent()}
</p>
<Button
variant="primary"
fullWidth
onclick={handleJoinChat}
disabled={isProvisioning}
>
{isProvisioning ? m.chat_joining() : m.chat_join_button()}
</Button>
<a
href="https://matrix.org"
target="_blank"
rel="noopener noreferrer"
class="inline-block mt-4 text-[12px] text-light/30 hover:text-light/50 transition-colors"
>
{m.chat_join_learn_more()} &rarr;
</a>
</div>
</div>

View File

@@ -10,6 +10,9 @@
deleteCard,
deleteColumn,
subscribeToBoard,
type RealtimeChangePayload,
type ColumnWithCards,
type BoardWithColumns,
} from "$lib/api/kanban";
import {
getLockInfo,
@@ -24,8 +27,8 @@
RealtimeChannel,
SupabaseClient,
} from "@supabase/supabase-js";
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
import type { Database, KanbanCard, KanbanColumn, Document } from "$lib/supabase/types";
import { untrack } from "svelte";
const log = createLogger("page.file-viewer");
@@ -194,16 +197,68 @@
}
});
$effect(() => {
// Incremental realtime handlers (avoid full refetch)
function handleColumnRealtime(payload: RealtimeChangePayload<KanbanColumn>) {
if (!kanbanBoard) return;
const { event } = payload;
if (event === "INSERT") {
const col: ColumnWithCards = { ...payload.new, cards: [] };
kanbanBoard = { ...kanbanBoard, columns: [...kanbanBoard.columns, col].sort((a: ColumnWithCards, b: ColumnWithCards) => a.position - b.position) };
} else if (event === "UPDATE") {
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((c: ColumnWithCards) => c.id === payload.new.id ? { ...c, ...payload.new } : c).sort((a: ColumnWithCards, b: ColumnWithCards) => a.position - b.position) };
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.filter((c: ColumnWithCards) => c.id !== deletedId) };
}
}
}
const colIds = kanbanBoard.columns.map((c) => c.id);
function handleCardRealtime(payload: RealtimeChangePayload<KanbanCard>) {
if (!kanbanBoard) return;
const { event } = payload;
// Skip realtime updates for cards in an in-flight optimistic move
if (event === "UPDATE" && optimisticMoveIds.size > 0) {
const cardId = payload.new?.id ?? payload.old?.id;
if (cardId && optimisticMoveIds.has(cardId)) return;
}
if (event === "INSERT") {
const card = payload.new;
if (!card.column_id) return;
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => col.id === card.column_id ? { ...col, cards: [...col.cards, card].sort((a: KanbanCard, b: KanbanCard) => a.position - b.position) } : col) };
} else if (event === "UPDATE") {
const card = payload.new;
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => {
if (col.id === card.column_id) {
const exists = col.cards.some((c: KanbanCard) => c.id === card.id);
const updatedCards = exists ? col.cards.map((c: KanbanCard) => c.id === card.id ? { ...c, ...card } : c) : [...col.cards, card];
return { ...col, cards: updatedCards.sort((a: KanbanCard, b: KanbanCard) => a.position - b.position) };
}
return { ...col, cards: col.cards.filter((c: KanbanCard) => c.id !== card.id) };
}) };
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({ ...col, cards: col.cards.filter((c: KanbanCard) => c.id !== deletedId) })) };
}
}
}
let currentKanbanBoardId = $derived(kanbanBoard?.id ?? null);
$effect(() => {
const boardId = currentKanbanBoardId;
if (!boardId) return;
const colIds = (untrack(() => kanbanBoard)?.columns ?? []).map((c: ColumnWithCards) => c.id);
const channel = subscribeToBoard(
supabase,
kanbanBoard.id,
boardId,
colIds,
() => loadKanbanBoard(),
() => loadKanbanBoard(),
handleColumnRealtime,
handleCardRealtime,
);
realtimeChannel = channel;
@@ -244,17 +299,77 @@
}
});
async function handleCardMove(
// Track card IDs with in-flight optimistic moves to suppress realtime echoes
let optimisticMoveIds = new Set<string>();
function handleCardMove(
cardId: string,
toColumnId: string,
toPosition: number,
) {
try {
await moveCard(supabase, cardId, toColumnId, toPosition);
} catch (err) {
log.error("Failed to move card", { error: err });
toasts.error("Failed to move card");
}
if (!kanbanBoard) return;
const fromColId = kanbanBoard.columns.find((c: ColumnWithCards) =>
c.cards.some((card: KanbanCard) => card.id === cardId),
)?.id;
if (!fromColId) return;
// Build fully immutable new columns for instant Svelte reactivity
let movedCard: KanbanCard | undefined;
const newColumns = kanbanBoard.columns.map((col: ColumnWithCards) => {
if (col.id === fromColId && fromColId !== toColumnId) {
const filtered = col.cards.filter((c: KanbanCard) => {
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
return true;
});
return { ...col, cards: filtered };
}
if (col.id === toColumnId && fromColId !== toColumnId) {
const cards = [...col.cards];
return { ...col, cards };
}
if (col.id === fromColId && fromColId === toColumnId) {
const card = col.cards.find((c: KanbanCard) => c.id === cardId);
if (!card) return col;
movedCard = { ...card };
const without = col.cards.filter((c: KanbanCard) => c.id !== cardId);
const cards = [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)];
return { ...col, cards };
}
return col;
});
const finalColumns = (fromColId !== toColumnId && movedCard)
? newColumns.map((col: ColumnWithCards) => {
if (col.id === toColumnId) {
const cards = [...col.cards];
cards.splice(toPosition, 0, movedCard!);
return { ...col, cards };
}
return col;
})
: newColumns;
// Track affected IDs for realtime suppression
const affectedIds = new Set<string>();
affectedIds.add(cardId);
const targetCol = finalColumns.find((c: ColumnWithCards) => c.id === toColumnId);
targetCol?.cards.forEach((c: KanbanCard) => affectedIds.add(c.id));
affectedIds.forEach((id: string) => optimisticMoveIds.add(id));
// Instant optimistic update
kanbanBoard = { ...kanbanBoard, columns: finalColumns };
// Persist in background
moveCard(supabase, cardId, toColumnId, toPosition)
.catch((err) => {
log.error("Failed to move card", { error: err });
toasts.error("Failed to move card");
loadKanbanBoard();
})
.finally(() => {
affectedIds.forEach((id) => optimisticMoveIds.delete(id));
});
}
function handleCardClick(card: KanbanCard) {
@@ -356,7 +471,7 @@
for (const col of json.columns) {
// Check if column already exists by name
let targetCol = kanbanBoard.columns.find(
(c) =>
(c: ColumnWithCards) =>
c.name.toLowerCase() ===
(col.name || "").toLowerCase(),
);
@@ -419,9 +534,9 @@
if (!kanbanBoard) return;
const exportData = {
board: kanbanBoard.name,
columns: kanbanBoard.columns.map((col) => ({
columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({
name: col.name,
cards: col.cards.map((card) => ({
cards: col.cards.map((card: KanbanCard) => ({
title: card.title,
description: card.description,
priority: card.priority,
@@ -545,9 +660,9 @@
if (kanbanBoard) {
kanbanBoard = {
...kanbanBoard,
columns: kanbanBoard.columns.map((col) => ({
columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({
...col,
cards: col.cards.map((c) =>
cards: col.cards.map((c: KanbanCard) =>
c.id === updatedCard.id ? updatedCard : c,
),
})),

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from "$app/stores";
import { page, navigating } from "$app/stores";
import { Avatar } from "$lib/components/ui";
import type { Snippet } from "svelte";
import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
@@ -68,10 +68,23 @@
]);
function isModuleActive(href: string, exact?: boolean): boolean {
const navTo = $navigating?.to?.url.pathname;
if (navTo) {
if (exact) return navTo === href;
return navTo.startsWith(href);
}
if (exact) return $page.url.pathname === href;
return $page.url.pathname.startsWith(href);
}
function isNavigatingToModule(href: string, exact?: boolean): boolean {
const navTo = $navigating?.to?.url.pathname;
if (!navTo) return false;
const isTarget = exact ? navTo === href : navTo.startsWith(href);
const isCurrent = exact ? $page.url.pathname === href : $page.url.pathname.startsWith(href);
return isTarget && !isCurrent;
}
function getStatusColor(status: string): string {
const map: Record<string, string> = {
planning: "bg-amber-400",
@@ -143,7 +156,10 @@
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{mod.icon}</span
>
{mod.label}
<span class="flex-1">{mod.label}</span>
{#if isNavigatingToModule(mod.href, mod.exact)}
<span class="block w-3.5 h-3.5 border-2 border-background/30 border-t-background rounded-full animate-spin shrink-0"></span>
{/if}
</a>
{/each}
</nav>

View File

@@ -1,5 +1,18 @@
import type { PageServerLoad } from './$types';
import { fetchTaskColumns } from '$lib/api/event-tasks';
import { createLogger } from '$lib/utils/logger';
export const load: PageServerLoad = async ({ parent }) => {
return await parent();
const log = createLogger('page.event-tasks');
export const load: PageServerLoad = async ({ parent, locals }) => {
const parentData = await parent();
const event = parentData.event;
try {
const taskColumns = await fetchTaskColumns(locals.supabase, event.id);
return { ...parentData, taskColumns };
} catch (e) {
log.error('Failed to load task columns', { error: e, data: { eventId: event.id } });
return { ...parentData, taskColumns: [] };
}
};

View File

@@ -1,32 +1,397 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { untrack } from "svelte";
import type { SupabaseClient, RealtimeChannel } from "@supabase/supabase-js";
import type { Database, EventTask, EventTaskColumn } from "$lib/supabase/types";
import {
type TaskColumnWithTasks,
type RealtimeChangePayload,
fetchTaskColumns,
createTaskColumn,
createTask,
deleteTask,
deleteTaskColumn,
renameTaskColumn,
moveTask,
subscribeToEventTasks,
} from "$lib/api/event-tasks";
import type { Event } from "$lib/api/events";
import { KanbanBoard } from "$lib/components/kanban";
import type { ColumnWithCards } from "$lib/api/kanban";
import type { KanbanCard } from "$lib/supabase/types";
import { Button, Modal, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import { createLogger } from "$lib/utils/logger";
import * as m from "$lib/paraglide/messages";
const log = createLogger("page.event-tasks");
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
userRole: string;
event: Event;
taskColumns: TaskColumnWithTasks[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let taskColumns = $state<TaskColumnWithTasks[]>(data.taskColumns);
let realtimeChannel = $state<RealtimeChannel | null>(null);
let optimisticMoveIds = new Set<string>();
// Add column modal
let showAddColumnModal = $state(false);
let newColumnName = $state("");
// Add card modal
let showAddCardModal = $state(false);
let targetColumnId = $state<string | null>(null);
let newCardTitle = $state("");
const canEdit = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Map TaskColumnWithTasks to ColumnWithCards for KanbanBoard component
const boardColumns = $derived<ColumnWithCards[]>(
taskColumns.map((col) => ({
...col,
board_id: data.event.id,
cards: col.cards as unknown as KanbanCard[],
})),
);
// ============================================================
// Realtime
// ============================================================
function handleColumnRealtime(payload: RealtimeChangePayload<EventTaskColumn>) {
const { event } = payload;
if (event === "INSERT") {
const col: TaskColumnWithTasks = { ...payload.new, cards: [] };
taskColumns = [...taskColumns, col].sort((a, b) => a.position - b.position);
} else if (event === "UPDATE") {
taskColumns = taskColumns.map((c) => c.id === payload.new.id ? { ...c, ...payload.new } : c).sort((a, b) => a.position - b.position);
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
taskColumns = taskColumns.filter((c) => c.id !== deletedId);
}
}
}
function handleTaskRealtime(payload: RealtimeChangePayload<EventTask>) {
const { event } = payload;
if (event === "UPDATE" && optimisticMoveIds.size > 0) {
const taskId = payload.new?.id ?? payload.old?.id;
if (taskId && optimisticMoveIds.has(taskId)) return;
}
if (event === "INSERT") {
const task = payload.new;
if (!task.column_id) return;
taskColumns = taskColumns.map((col) =>
col.id === task.column_id
? { ...col, cards: [...col.cards, task].sort((a, b) => a.position - b.position) }
: col,
);
} else if (event === "UPDATE") {
const task = payload.new;
taskColumns = taskColumns.map((col) => {
if (col.id === task.column_id) {
const exists = col.cards.some((c) => c.id === task.id);
const updatedCards = exists
? col.cards.map((c) => (c.id === task.id ? { ...c, ...task } : c))
: [...col.cards, task];
return { ...col, cards: updatedCards.sort((a, b) => a.position - b.position) };
}
return { ...col, cards: col.cards.filter((c) => c.id !== task.id) };
});
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
taskColumns = taskColumns.map((col) => ({
...col,
cards: col.cards.filter((c) => c.id !== deletedId),
}));
}
}
}
let currentEventId = $derived(data.event.id);
$effect(() => {
const eventId = currentEventId;
if (!eventId) return;
const colIds = (untrack(() => taskColumns)).map((c) => c.id);
const channel = subscribeToEventTasks(
supabase,
eventId,
colIds,
handleColumnRealtime,
handleTaskRealtime,
);
realtimeChannel = channel;
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
});
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
});
// ============================================================
// Handlers
// ============================================================
async function reloadColumns() {
try {
taskColumns = await fetchTaskColumns(supabase, data.event.id);
} catch (e) {
log.error("Failed to reload task columns", { error: e });
}
}
function handleCardMove(cardId: string, toColumnId: string, toPosition: number) {
const fromColId = taskColumns.find((c) =>
c.cards.some((card) => card.id === cardId),
)?.id;
if (!fromColId) return;
// Optimistic update
let movedCard: EventTask | undefined;
const newColumns = taskColumns.map((col) => {
if (col.id === fromColId && fromColId !== toColumnId) {
const filtered = col.cards.filter((c) => {
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
return true;
});
return { ...col, cards: filtered };
}
if (col.id === toColumnId && fromColId !== toColumnId) {
return { ...col, cards: [...col.cards] };
}
if (col.id === fromColId && fromColId === toColumnId) {
const card = col.cards.find((c) => c.id === cardId);
if (!card) return col;
movedCard = { ...card };
const without = col.cards.filter((c) => c.id !== cardId);
return { ...col, cards: [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)] };
}
return col;
});
const finalColumns = (fromColId !== toColumnId && movedCard)
? newColumns.map((col) => {
if (col.id === toColumnId) {
const cards = [...col.cards];
cards.splice(toPosition, 0, movedCard!);
return { ...col, cards };
}
return col;
})
: newColumns;
const affectedIds = new Set<string>();
affectedIds.add(cardId);
const targetCol = finalColumns.find((c) => c.id === toColumnId);
targetCol?.cards.forEach((c) => affectedIds.add(c.id));
affectedIds.forEach((id) => optimisticMoveIds.add(id));
taskColumns = finalColumns;
moveTask(supabase, cardId, toColumnId, toPosition)
.catch((err) => {
log.error("Failed to persist task move", { error: err });
reloadColumns();
})
.finally(() => {
affectedIds.forEach((id) => optimisticMoveIds.delete(id));
});
}
async function handleAddColumn() {
const name = newColumnName.trim();
if (!name) return;
try {
await createTaskColumn(supabase, data.event.id, name);
newColumnName = "";
showAddColumnModal = false;
await reloadColumns();
} catch (e) {
log.error("Failed to create column", { error: e });
toasts.error("Failed to create column");
}
}
async function handleDeleteColumn(columnId: string) {
try {
await deleteTaskColumn(supabase, columnId);
taskColumns = taskColumns.filter((c) => c.id !== columnId);
} catch (e) {
log.error("Failed to delete column", { error: e });
toasts.error("Failed to delete column");
}
}
async function handleRenameColumn(columnId: string, newName: string) {
try {
await renameTaskColumn(supabase, columnId, newName);
taskColumns = taskColumns.map((c) =>
c.id === columnId ? { ...c, name: newName } : c,
);
} catch (e) {
log.error("Failed to rename column", { error: e });
toasts.error("Failed to rename column");
}
}
function handleAddCard(columnId: string) {
targetColumnId = columnId;
newCardTitle = "";
showAddCardModal = true;
}
async function submitAddCard() {
const title = newCardTitle.trim();
if (!title || !targetColumnId) return;
try {
await createTask(supabase, data.event.id, targetColumnId, title, data.event.created_by ?? undefined);
newCardTitle = "";
showAddCardModal = false;
await reloadColumns();
} catch (e) {
log.error("Failed to create task", { error: e });
toasts.error("Failed to create task");
}
}
async function handleDeleteCard(cardId: string) {
try {
await deleteTask(supabase, cardId);
taskColumns = taskColumns.map((col) => ({
...col,
cards: col.cards.filter((c) => c.id !== cardId),
}));
} catch (e) {
log.error("Failed to delete task", { error: e });
toasts.error("Failed to delete task");
}
}
</script>
<svelte:head>
<title>{m.events_mod_tasks()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>task_alt</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_tasks()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_tasks_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
<div class="flex flex-col h-full">
<!-- Toolbar -->
<div class="flex items-center justify-between px-4 py-3 border-b border-light/5">
<h2 class="text-h4 font-heading text-white">{m.events_mod_tasks()}</h2>
</div>
<!-- Board -->
<div class="flex-1 overflow-hidden p-4">
{#if boardColumns.length > 0}
<KanbanBoard
columns={boardColumns}
onCardMove={handleCardMove}
onAddCard={handleAddCard}
onAddColumn={() => (showAddColumnModal = true)}
onDeleteCard={handleDeleteCard}
onDeleteColumn={handleDeleteColumn}
onRenameColumn={handleRenameColumn}
{canEdit}
/>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<span
class="material-symbols-rounded mb-4 block text-[48px] leading-none"
>task_alt</span
>
<p class="text-body-sm mb-4">No task columns yet</p>
{#if canEdit}
<Button
variant="secondary"
icon="add"
onclick={() => (showAddColumnModal = true)}
>
Add column
</Button>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Add Column Modal -->
<Modal
isOpen={showAddColumnModal}
title="Add Column"
onClose={() => (showAddColumnModal = false)}
>
<form
onsubmit={(e) => {
e.preventDefault();
handleAddColumn();
}}
class="flex flex-col gap-4"
>
<Input
label="Column name"
placeholder="e.g. In Review"
bind:value={newColumnName}
/>
<div class="flex justify-end gap-2">
<Button
variant="secondary"
onclick={() => (showAddColumnModal = false)}>Cancel</Button
>
<Button type="submit" disabled={!newColumnName.trim()}>Create</Button>
</div>
</form>
</Modal>
<!-- Add Card Modal -->
<Modal
isOpen={showAddCardModal}
title="Add Task"
onClose={() => (showAddCardModal = false)}
>
<form
onsubmit={(e) => {
e.preventDefault();
submitAddCard();
}}
class="flex flex-col gap-4"
>
<Input
label="Task title"
placeholder="What needs to be done?"
bind:value={newCardTitle}
/>
<div class="flex justify-end gap-2">
<Button
variant="secondary"
onclick={() => (showAddCardModal = false)}>Cancel</Button
>
<Button type="submit" disabled={!newCardTitle.trim()}>Add Task</Button>
</div>
</form>
</Modal>

View File

@@ -73,6 +73,9 @@
let cardModalMode = $state<"edit" | "create">("edit");
let realtimeChannel = $state<RealtimeChannel | null>(null);
// Track card IDs with in-flight optimistic moves to suppress realtime echoes
let optimisticMoveIds = new Set<string>();
async function loadBoard(boardId: string) {
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
@@ -118,6 +121,12 @@
if (!selectedBoard) return;
const { event } = payload;
// Skip realtime updates for cards that are part of an in-flight optimistic move
if (event === "UPDATE" && optimisticMoveIds.size > 0) {
const cardId = payload.new?.id ?? payload.old?.id;
if (cardId && optimisticMoveIds.has(cardId)) return;
}
if (event === "INSERT") {
const card = payload.new;
if (!card.column_id) return;
@@ -412,37 +421,73 @@
}
}
async function handleCardMove(
function handleCardMove(
cardId: string,
toColumnId: string,
toPosition: number,
) {
if (!selectedBoard) return;
// Optimistic UI update - move card immediately
const fromColumn = selectedBoard.columns.find((c) =>
const fromColId = selectedBoard.columns.find((c) =>
c.cards.some((card) => card.id === cardId),
);
const toColumn = selectedBoard.columns.find((c) => c.id === toColumnId);
)?.id;
if (!fromColId) return;
if (!fromColumn || !toColumn) return;
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
if (cardIndex === -1) return;
const [movedCard] = fromColumn.cards.splice(cardIndex, 1);
movedCard.column_id = toColumnId;
toColumn.cards.splice(toPosition, 0, movedCard);
// Trigger reactivity
selectedBoard = { ...selectedBoard };
// Persist to database in background
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
log.error("Failed to persist card move", { error: err });
// Reload to sync state on error
loadBoard(selectedBoard!.id);
// Build fully immutable new columns so Svelte sees fresh references instantly
let movedCard: KanbanCard | undefined;
const newColumns = selectedBoard.columns.map((col) => {
if (col.id === fromColId && fromColId !== toColumnId) {
const filtered = col.cards.filter((c) => {
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
return true;
});
return { ...col, cards: filtered };
}
if (col.id === toColumnId && fromColId !== toColumnId) {
const cards = [...col.cards];
return { ...col, cards, _insertAt: toPosition } as typeof col & { _insertAt: number };
}
if (col.id === fromColId && fromColId === toColumnId) {
const card = col.cards.find((c) => c.id === cardId);
if (!card) return col;
movedCard = { ...card };
const without = col.cards.filter((c) => c.id !== cardId);
const cards = [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)];
return { ...col, cards };
}
return col;
});
const finalColumns = (fromColId !== toColumnId && movedCard)
? newColumns.map((col) => {
if (col.id === toColumnId) {
const cards = [...col.cards];
cards.splice(toPosition, 0, movedCard!);
return { ...col, cards };
}
return col;
})
: newColumns;
const affectedIds = new Set<string>();
affectedIds.add(cardId);
const targetCol = finalColumns.find((c) => c.id === toColumnId);
targetCol?.cards.forEach((c) => affectedIds.add(c.id));
affectedIds.forEach((id) => optimisticMoveIds.add(id));
// Single synchronous assignment — Svelte picks this up instantly
selectedBoard = { ...selectedBoard, columns: finalColumns };
// Persist to database in background (fire-and-forget, NOT awaited)
moveCard(supabase, cardId, toColumnId, toPosition)
.catch((err) => {
log.error("Failed to persist card move", { error: err });
loadBoard(selectedBoard!.id);
})
.finally(() => {
affectedIds.forEach((id) => optimisticMoveIds.delete(id));
});
}
function handleCardClick(card: KanbanCard) {

View File

@@ -0,0 +1,234 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
/**
* POST /api/matrix-provision
*
* Auto-provisions a Matrix account for the authenticated Supabase user.
* Uses the Synapse Admin API to:
* 1. Register a new Matrix user (or retrieve existing)
* 2. Login to get a fresh access token
* 3. Set display name and avatar from the Supabase profile
* 4. Store credentials in matrix_credentials table
*
* Body: { org_id: string }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.safeGetSession();
if (!session.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!env.MATRIX_HOMESERVER_URL || !env.MATRIX_ADMIN_TOKEN) {
return json({ error: 'Matrix integration not configured' }, { status: 503 });
}
const body = await request.json();
const { org_id } = body;
if (!org_id) {
return json({ error: 'org_id is required' }, { status: 400 });
}
// Check if credentials already exist
const { data: existing } = await locals.supabase
.from('matrix_credentials')
.select('homeserver_url, matrix_user_id, access_token, device_id')
.eq('user_id', session.user.id)
.eq('org_id', org_id)
.single();
if (existing) {
return json({ credentials: existing, provisioned: false });
}
// Fetch user profile from Supabase
const { data: profile } = await locals.supabase
.from('profiles')
.select('email, full_name, avatar_url')
.eq('id', session.user.id)
.single();
if (!profile) {
return json({ error: 'User profile not found' }, { status: 404 });
}
// After the guard above, these are guaranteed to be defined
const homeserverUrl = env.MATRIX_HOMESERVER_URL!;
const adminToken = env.MATRIX_ADMIN_TOKEN!;
// Derive Matrix username from Supabase user ID (safe, unique, no collisions)
const matrixLocalpart = `root_${session.user.id.replace(/-/g, '')}`;
const password = generatePassword();
try {
// Step 1: Register user via Synapse Admin API
const registerRes = await fetch(
`${homeserverUrl}/_synapse/admin/v2/users/@${matrixLocalpart}:${getServerName(homeserverUrl)}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
password,
displayname: profile.full_name || profile.email,
admin: false,
deactivated: false,
}),
}
);
if (!registerRes.ok) {
const err = await registerRes.json().catch(() => ({}));
console.error('Matrix register failed:', registerRes.status, err);
return json({ error: 'Failed to create Matrix account' }, { status: 500 });
}
// Step 2: Login to get access token
const loginRes = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: matrixLocalpart,
},
password,
initial_device_display_name: 'Root v2 Web',
}),
});
if (!loginRes.ok) {
const err = await loginRes.json().catch(() => ({}));
console.error('Matrix login failed:', loginRes.status, err);
return json({ error: 'Failed to login to Matrix account' }, { status: 500 });
}
const loginData = await loginRes.json();
const matrixUserId = loginData.user_id;
const accessToken = loginData.access_token;
const deviceId = loginData.device_id;
// Step 3: Set avatar if available
if (profile.avatar_url) {
try {
await setMatrixAvatar(homeserverUrl, accessToken, profile.avatar_url);
} catch (e) {
console.warn('Failed to set Matrix avatar:', e);
}
}
// Step 4: Store credentials in Supabase
const { error: upsertError } = await locals.supabase
.from('matrix_credentials')
.upsert(
{
user_id: session.user.id,
org_id,
homeserver_url: homeserverUrl,
matrix_user_id: matrixUserId,
access_token: accessToken,
device_id: deviceId ?? null,
},
{ onConflict: 'user_id,org_id' }
);
if (upsertError) {
console.error('Failed to store Matrix credentials:', upsertError);
return json({ error: 'Failed to store credentials' }, { status: 500 });
}
return json({
credentials: {
homeserver_url: homeserverUrl,
matrix_user_id: matrixUserId,
access_token: accessToken,
device_id: deviceId,
},
provisioned: true,
});
} catch (e) {
console.error('Matrix provisioning error:', e);
return json({ error: 'Matrix provisioning failed' }, { status: 500 });
}
};
/**
* Extract the server name from the homeserver URL.
* e.g. "https://matrix.example.com" -> "matrix.example.com"
*/
function getServerName(homeserverUrl: string): string {
try {
return new URL(homeserverUrl).hostname;
} catch {
return homeserverUrl.replace(/^https?:\/\//, '');
}
}
/**
* Generate a random password for the Matrix account.
* The user never sees this — auth is token-based after provisioning.
*/
function generatePassword(): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
const array = new Uint8Array(32);
crypto.getRandomValues(array);
for (const byte of array) {
password += chars[byte % chars.length];
}
return password;
}
/**
* Download an avatar from a URL and upload it to Matrix, then set as profile avatar.
*/
async function setMatrixAvatar(homeserverUrl: string, accessToken: string, avatarUrl: string): Promise<void> {
// Download the avatar
const imgRes = await fetch(avatarUrl);
if (!imgRes.ok) return;
const blob = await imgRes.blob();
const contentType = imgRes.headers.get('content-type') || 'image/png';
// Upload to Matrix content repository
const uploadRes = await fetch(
`${homeserverUrl}/_matrix/media/v3/upload?filename=avatar`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': contentType,
},
body: blob,
}
);
if (!uploadRes.ok) return;
const { content_uri } = await uploadRes.json();
// Set as profile avatar
// We need the user ID from the access token — extract from a whoami call
const whoamiRes = await fetch(`${homeserverUrl}/_matrix/client/v3/account/whoami`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!whoamiRes.ok) return;
const { user_id } = await whoamiRes.json();
await fetch(
`${homeserverUrl}/_matrix/client/v3/profile/${encodeURIComponent(user_id)}/avatar_url`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ avatar_url: content_uri }),
}
);
}