Mega push vol 6, started adding many awesome stuff, chat broken rn
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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()} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
234
src/routes/api/matrix-provision/+server.ts
Normal file
234
src/routes/api/matrix-provision/+server.ts
Normal 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 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user