Mega push vol 6, started adding many awesome stuff, chat broken rn
This commit is contained in:
@@ -7,3 +7,10 @@ GOOGLE_API_KEY=your_google_api_key
|
|||||||
# Paste the full JSON key file contents, or base64-encode it
|
# Paste the full JSON key file contents, or base64-encode it
|
||||||
# The calendar must be shared with the service account email (with "Make changes to events" permission)
|
# The calendar must be shared with the service account email (with "Make changes to events" permission)
|
||||||
GOOGLE_SERVICE_ACCOUNT_KEY=
|
GOOGLE_SERVICE_ACCOUNT_KEY=
|
||||||
|
|
||||||
|
# Matrix / Synapse integration
|
||||||
|
# The homeserver URL where your Synapse instance is running
|
||||||
|
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||||
|
# Synapse Admin API shared secret or admin access token
|
||||||
|
# Used to auto-provision Matrix accounts for users
|
||||||
|
MATRIX_ADMIN_TOKEN=
|
||||||
|
|||||||
@@ -378,5 +378,14 @@
|
|||||||
"overview_upcoming_events": "Upcoming Events",
|
"overview_upcoming_events": "Upcoming Events",
|
||||||
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
|
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
|
||||||
"overview_view_all_events": "View all events",
|
"overview_view_all_events": "View all events",
|
||||||
"overview_more_members": "+{count} more"
|
"overview_more_members": "+{count} more",
|
||||||
|
"chat_join_title": "Join Chat",
|
||||||
|
"chat_join_description": "Chat is powered by Matrix, an open standard for secure, decentralized communication.",
|
||||||
|
"chat_join_consent": "By joining, a Matrix account will be created for you using your current profile details (name, email, and avatar).",
|
||||||
|
"chat_join_learn_more": "Learn more about Matrix",
|
||||||
|
"chat_join_button": "Join Chat",
|
||||||
|
"chat_joining": "Setting up your account...",
|
||||||
|
"chat_join_success": "Chat account created! Welcome.",
|
||||||
|
"chat_join_error": "Failed to set up chat. Please try again.",
|
||||||
|
"chat_disconnect": "Disconnect from Chat"
|
||||||
}
|
}
|
||||||
@@ -378,5 +378,14 @@
|
|||||||
"overview_upcoming_events": "Tulevased üritused",
|
"overview_upcoming_events": "Tulevased üritused",
|
||||||
"overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.",
|
"overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.",
|
||||||
"overview_view_all_events": "Vaata kõiki üritusi",
|
"overview_view_all_events": "Vaata kõiki üritusi",
|
||||||
"overview_more_members": "+{count} veel"
|
"overview_more_members": "+{count} veel",
|
||||||
|
"chat_join_title": "Liitu vestlusega",
|
||||||
|
"chat_join_description": "Vestlus põhineb Matrixil — avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.",
|
||||||
|
"chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) põhjal.",
|
||||||
|
"chat_join_learn_more": "Loe Matrixi kohta lähemalt",
|
||||||
|
"chat_join_button": "Liitu vestlusega",
|
||||||
|
"chat_joining": "Konto seadistamine...",
|
||||||
|
"chat_join_success": "Vestluskonto loodud! Tere tulemast.",
|
||||||
|
"chat_join_error": "Vestluse seadistamine ebaõnnestus. Proovi uuesti.",
|
||||||
|
"chat_disconnect": "Katkesta vestlusühendus"
|
||||||
}
|
}
|
||||||
266
src/lib/api/event-tasks.ts
Normal file
266
src/lib/api/event-tasks.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database, EventTaskColumn, EventTask } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('api.event-tasks');
|
||||||
|
|
||||||
|
export interface TaskColumnWithTasks extends EventTaskColumn {
|
||||||
|
cards: EventTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Columns
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function fetchTaskColumns(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string
|
||||||
|
): Promise<TaskColumnWithTasks[]> {
|
||||||
|
const { data: columns, error: colErr } = await supabase
|
||||||
|
.from('event_task_columns')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.order('position');
|
||||||
|
|
||||||
|
if (colErr) {
|
||||||
|
log.error('Failed to fetch task columns', { error: colErr, data: { eventId } });
|
||||||
|
throw colErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: tasks, error: taskErr } = await supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.order('position');
|
||||||
|
|
||||||
|
if (taskErr) {
|
||||||
|
log.error('Failed to fetch tasks', { error: taskErr, data: { eventId } });
|
||||||
|
throw taskErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksByColumn = new Map<string, EventTask[]>();
|
||||||
|
for (const task of tasks ?? []) {
|
||||||
|
const arr = tasksByColumn.get(task.column_id) ?? [];
|
||||||
|
arr.push(task);
|
||||||
|
tasksByColumn.set(task.column_id, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (columns ?? []).map((col) => ({
|
||||||
|
...col,
|
||||||
|
cards: tasksByColumn.get(col.id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTaskColumn(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
name: string,
|
||||||
|
position?: number
|
||||||
|
): Promise<EventTaskColumn> {
|
||||||
|
if (position === undefined) {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('event_task_columns')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('event_id', eventId);
|
||||||
|
position = count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('event_task_columns')
|
||||||
|
.insert({ event_id: eventId, name, position })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
log.error('Failed to create task column', { error, data: { eventId, name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameTaskColumn(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
columnId: string,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('event_task_columns')
|
||||||
|
.update({ name })
|
||||||
|
.eq('id', columnId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to rename task column', { error, data: { columnId, name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTaskColumn(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
columnId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('event_task_columns')
|
||||||
|
.delete()
|
||||||
|
.eq('id', columnId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to delete task column', { error, data: { columnId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tasks
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function createTask(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
columnId: string,
|
||||||
|
title: string,
|
||||||
|
createdBy?: string
|
||||||
|
): Promise<EventTask> {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('column_id', columnId);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.insert({
|
||||||
|
event_id: eventId,
|
||||||
|
column_id: columnId,
|
||||||
|
title,
|
||||||
|
position: count ?? 0,
|
||||||
|
created_by: createdBy ?? null,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
log.error('Failed to create task', { error, data: { eventId, columnId, title } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTask(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
taskId: string,
|
||||||
|
updates: Partial<Pick<EventTask, 'title' | 'description' | 'priority' | 'due_date' | 'color' | 'assignee_id'>>
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', taskId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to update task', { error, data: { taskId, updates } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTask(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
taskId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.delete()
|
||||||
|
.eq('id', taskId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to delete task', { error, data: { taskId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveTask(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
taskId: string,
|
||||||
|
newColumnId: string,
|
||||||
|
newPosition: number
|
||||||
|
): Promise<void> {
|
||||||
|
// Fetch all tasks in the target column
|
||||||
|
const { data: colTasks, error: fetchErr } = await supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.select('id, position')
|
||||||
|
.eq('column_id', newColumnId)
|
||||||
|
.order('position');
|
||||||
|
|
||||||
|
if (fetchErr) {
|
||||||
|
log.error('Failed to fetch column tasks for reorder', { error: fetchErr });
|
||||||
|
throw fetchErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the new order
|
||||||
|
const existing = (colTasks ?? []).filter((t) => t.id !== taskId);
|
||||||
|
existing.splice(newPosition, 0, { id: taskId, position: newPosition });
|
||||||
|
|
||||||
|
// Update positions + column for changed tasks
|
||||||
|
const updates = existing
|
||||||
|
.map((t, i) => ({ id: t.id, position: i, column_id: newColumnId }))
|
||||||
|
.filter((t, i) => {
|
||||||
|
const orig = colTasks?.find((c) => c.id === t.id);
|
||||||
|
return !orig || orig.position !== i || t.id === taskId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
updates.map((u) =>
|
||||||
|
supabase
|
||||||
|
.from('event_tasks')
|
||||||
|
.update({ column_id: u.column_id, position: u.position, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', u.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Realtime
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface RealtimeChangePayload<T = Record<string, unknown>> {
|
||||||
|
event: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||||
|
new: T;
|
||||||
|
old: Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToEventTasks(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
columnIds: string[],
|
||||||
|
onColumnChange: (payload: RealtimeChangePayload<EventTaskColumn>) => void,
|
||||||
|
onTaskChange: (payload: RealtimeChangePayload<EventTask>) => void
|
||||||
|
) {
|
||||||
|
const channel = supabase.channel(`event-tasks:${eventId}`);
|
||||||
|
const columnIdSet = new Set(columnIds);
|
||||||
|
|
||||||
|
channel
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'event_task_columns', filter: `event_id=eq.${eventId}` },
|
||||||
|
(payload) => onColumnChange({
|
||||||
|
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
|
||||||
|
new: payload.new as EventTaskColumn,
|
||||||
|
old: payload.old as Partial<EventTaskColumn>,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'event_tasks', filter: `event_id=eq.${eventId}` },
|
||||||
|
(payload) => {
|
||||||
|
const task = (payload.new ?? payload.old) as Partial<EventTask>;
|
||||||
|
const colId = task.column_id ?? (payload.old as Partial<EventTask>)?.column_id;
|
||||||
|
if (colId && !columnIdSet.has(colId)) return;
|
||||||
|
|
||||||
|
onTaskChange({
|
||||||
|
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
|
||||||
|
new: payload.new as EventTask,
|
||||||
|
old: payload.old as Partial<EventTask>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
@@ -83,6 +83,13 @@
|
|||||||
dragOverCardIndex = null;
|
dragOverCardIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropIndicatorClass(card: KanbanCard, cardIndex: number, columnId: string, totalCards: number): string {
|
||||||
|
if (!draggedCard || draggedCard.id === card.id || dragOverCardIndex?.columnId !== columnId) return '';
|
||||||
|
if (dragOverCardIndex.index === cardIndex) return 'shadow-[0_-3px_0_0_var(--color-primary)]';
|
||||||
|
if (dragOverCardIndex.index === cardIndex + 1 && cardIndex === totalCards - 1) return 'shadow-[0_3px_0_0_var(--color-primary)]';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
|
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -92,6 +99,12 @@
|
|||||||
const midY = rect.top + rect.height / 2;
|
const midY = rect.top + rect.height / 2;
|
||||||
const dropIndex = e.clientY < midY ? index : index + 1;
|
const dropIndex = e.clientY < midY ? index : index + 1;
|
||||||
|
|
||||||
|
// Skip update if the index hasn't changed (prevents flicker)
|
||||||
|
if (
|
||||||
|
dragOverCardIndex?.columnId === columnId &&
|
||||||
|
dragOverCardIndex?.index === dropIndex
|
||||||
|
) return;
|
||||||
|
|
||||||
dragOverColumn = columnId;
|
dragOverColumn = columnId;
|
||||||
dragOverCardIndex = { columnId, index: dropIndex };
|
dragOverCardIndex = { columnId, index: dropIndex };
|
||||||
}
|
}
|
||||||
@@ -144,6 +157,7 @@
|
|||||||
column.id
|
column.id
|
||||||
? 'ring-2 ring-primary'
|
? 'ring-2 ring-primary'
|
||||||
: ''}"
|
: ''}"
|
||||||
|
data-column-id={column.id}
|
||||||
ondragover={(e) => handleColumnDragOver(e, column.id)}
|
ondragover={(e) => handleColumnDragOver(e, column.id)}
|
||||||
ondragleave={handleColumnDragLeave}
|
ondragleave={handleColumnDragLeave}
|
||||||
ondrop={(e) => handleDrop(e, column.id)}
|
ondrop={(e) => handleDrop(e, column.id)}
|
||||||
@@ -238,14 +252,8 @@
|
|||||||
<!-- Cards -->
|
<!-- Cards -->
|
||||||
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
|
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
|
||||||
{#each column.cards as card, cardIndex}
|
{#each column.cards as card, cardIndex}
|
||||||
<!-- Drop indicator before card -->
|
|
||||||
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id}
|
|
||||||
<div
|
|
||||||
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
<div
|
<div
|
||||||
class="mb-2"
|
class="mb-2 relative {dropIndicatorClass(card, cardIndex, column.id, column.cards.length)}"
|
||||||
ondragover={(e) =>
|
ondragover={(e) =>
|
||||||
handleCardDragOver(e, column.id, cardIndex)}
|
handleCardDragOver(e, column.id, cardIndex)}
|
||||||
>
|
>
|
||||||
@@ -261,12 +269,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<!-- Drop indicator at end of column -->
|
|
||||||
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length}
|
|
||||||
<div
|
|
||||||
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Card Button -->
|
<!-- Add Card Button -->
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
ondelete?: (cardId: string) => void;
|
ondelete?: (cardId: string) => void;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
ondragstart?: (e: DragEvent) => void;
|
ondragstart?: (e: DragEvent) => void;
|
||||||
|
ondragend?: (e: DragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
ondelete,
|
ondelete,
|
||||||
draggable = true,
|
draggable = true,
|
||||||
ondragstart,
|
ondragstart,
|
||||||
|
ondragend,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleDelete(e: MouseEvent) {
|
function handleDelete(e: MouseEvent) {
|
||||||
@@ -59,8 +61,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="bg-night/80 border border-light/5 hover:border-light/10 rounded-xl px-3 py-2.5 cursor-pointer transition-all group w-full text-left flex flex-col gap-1.5 relative"
|
class="bg-night/80 border border-light/5 hover:border-light/10 rounded-xl px-3 py-2.5 cursor-pointer transition-all group w-full text-left flex flex-col gap-1.5 relative"
|
||||||
class:opacity-50={isDragging}
|
class:opacity-50={isDragging}
|
||||||
|
data-card-id={card.id}
|
||||||
{draggable}
|
{draggable}
|
||||||
{ondragstart}
|
{ondragstart}
|
||||||
|
{ondragend}
|
||||||
{onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
<!-- Delete button (top-right, visible on hover) -->
|
<!-- Delete button (top-right, visible on hover) -->
|
||||||
|
|||||||
@@ -1,54 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
showText?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { size = "md", showText = false }: Props = $props();
|
let { size = "md"}: Props = $props();
|
||||||
|
|
||||||
const iconSizes = {
|
const iconSizes = {
|
||||||
sm: "w-8 h-8",
|
sm: "w-8 h-8",
|
||||||
md: "w-10 h-10",
|
md: "w-10 h-10",
|
||||||
lg: "w-12 h-12",
|
lg: "w-12 h-12",
|
||||||
};
|
};
|
||||||
|
|
||||||
const textSizes = {
|
|
||||||
sm: "text-[14px]",
|
|
||||||
md: "text-[18px]",
|
|
||||||
lg: "text-[22px]",
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="shrink-0 {iconSizes[size]} transition-all duration-300">
|
<div class="shrink-0 {iconSizes[size]} transition-all duration-300">
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" width="38" height="21" viewBox="0 0 38 21" fill="none">
|
||||||
viewBox="0 0 38 21"
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.9111 0V2.61267H29.5355V5.4138L31.9111 5.4153V12.7031H35.1244V5.4153H37.5V2.61301H35.1244V0.000337601L31.9111 0ZM5.58767 2.37769C5.23245 2.38519 4.89438 2.43094 4.57528 2.51844C4.06906 2.65729 3.61441 2.86428 3.21203 3.14195V2.61267H0V12.7027H3.21203V7.23079C3.21203 6.53662 3.45949 6.03894 3.95272 5.73601C4.33617 5.49071 4.88257 5.38553 5.58767 5.41496V2.37769Z" fill="#E5E6F0"/>
|
||||||
fill="none"
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.7211 2.30908C10.9683 2.30908 10.2674 2.44188 9.61838 2.70692C8.96933 2.95935 8.40458 3.31827 7.92436 3.78526C7.44408 4.25226 7.06757 4.80136 6.79499 5.43244C6.53539 6.06352 6.40515 6.75138 6.40515 7.49605C6.40515 8.24072 6.53539 8.92862 6.79499 9.55967C7.06757 10.1907 7.44408 10.7459 7.92436 11.2254C8.40458 11.6924 8.96933 12.0585 9.61838 12.3236C10.2674 12.576 10.9683 12.7028 11.7211 12.7028C12.4869 12.7028 13.1879 12.576 13.8239 12.3236C14.4729 12.0585 15.0377 11.6924 15.5179 11.2254C15.9982 10.7459 16.3676 10.1907 16.6271 9.55967C16.8998 8.92862 17.0359 8.24072 17.0359 7.49605C17.0359 6.75138 16.8998 6.06352 16.6271 5.43244C16.3676 4.80136 15.9982 4.25226 15.5179 3.78526C15.0377 3.31827 14.4729 2.95935 13.8239 2.70692C13.1879 2.44188 12.4869 2.30908 11.7211 2.30908ZM11.7211 5.12999C12.3572 5.12999 12.8627 5.35012 13.2392 5.79189C13.6286 6.22101 13.8239 6.78925 13.8239 7.49606C13.8239 8.19024 13.6285 8.76445 13.2392 9.21887C12.8627 9.66062 12.3572 9.88187 11.7211 9.88187C11.0851 9.88187 10.5725 9.66062 10.1831 9.21887C9.80663 8.76445 9.61838 8.19024 9.61838 7.49606C9.61838 6.78925 9.80663 6.22101 10.1831 5.79189C10.5725 5.35013 11.0851 5.12999 11.7211 5.12999Z" fill="#E5E6F0"/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4405 2.30908C22.6878 2.30908 21.9868 2.44188 21.3378 2.70692C20.6888 2.95935 20.124 3.31827 19.6438 3.78526C19.1635 4.25226 18.787 4.80136 18.5144 5.43244C18.2548 6.06352 18.1246 6.75138 18.1246 7.49605C18.1246 8.24072 18.2548 8.92862 18.5144 9.55967C18.787 10.1907 19.1635 10.7459 19.6438 11.2254C20.124 11.6924 20.6888 12.0585 21.3378 12.3236C21.9868 12.576 22.6878 12.7028 23.4405 12.7028C24.2064 12.7028 24.9073 12.576 25.5433 12.3236C26.1924 12.0585 26.7571 11.6924 27.2373 11.2254C27.7176 10.7459 28.087 10.1907 28.3466 9.55967C28.6192 8.92862 28.7553 8.24072 28.7553 7.49605C28.7553 6.75138 28.6192 6.06352 28.3466 5.43244C28.087 4.80136 27.7176 4.25226 27.2373 3.78526C26.7571 3.31827 26.1924 2.95935 25.5433 2.70692C24.9073 2.44188 24.2064 2.30908 23.4405 2.30908ZM23.4405 5.12999C24.0766 5.12999 24.5822 5.35012 24.9586 5.79189C25.348 6.22101 25.5433 6.78925 25.5433 7.49606C25.5433 8.19024 25.3479 8.76445 24.9586 9.21887C24.5822 9.66062 24.0766 9.88187 23.4405 9.88187C22.8045 9.88187 22.2919 9.66062 21.9025 9.21887C21.5261 8.76445 21.3378 8.19024 21.3378 7.49606C21.3378 6.78925 21.5261 6.22101 21.9025 5.79189C22.2919 5.35013 22.8045 5.12999 23.4405 5.12999Z" fill="#E5E6F0"/>
|
||||||
class="w-full h-auto"
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2456 15.0433C12.2456 15.788 12.3758 16.4758 12.6355 17.107C12.908 17.738 13.2845 18.2931 13.7648 18.7727C14.2451 19.2397 14.8098 19.6058 15.4589 19.8709C16.1078 20.1232 16.8088 20.2501 17.5616 20.2501C18.3274 20.2501 19.0283 20.1233 19.6643 19.8709C20.3134 19.6058 20.8781 19.2397 21.3584 18.7727C21.8387 18.2931 22.2092 17.738 22.4688 17.107C22.7414 16.4758 22.8776 15.788 22.8776 15.0433H19.6643C19.6643 15.7375 19.4702 16.3117 19.0808 16.7661C18.7043 17.2078 18.1976 17.4292 17.5616 17.4292C16.9256 17.4292 16.4117 17.2078 16.0223 16.7661C15.6459 16.3117 15.4589 15.7375 15.4589 15.0433H12.2456Z" fill="#E5E6F0"/>
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
|
|
||||||
fill="#00A3E0"
|
|
||||||
fill-opacity="0.2"
|
|
||||||
/>
|
|
||||||
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
|
|
||||||
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
|
|
||||||
<path
|
|
||||||
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
|
|
||||||
stroke="#00A3E0"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{#if showText}
|
|
||||||
<span
|
|
||||||
class="font-heading {textSizes[
|
|
||||||
size
|
|
||||||
]} text-primary leading-none whitespace-nowrap transition-all duration-300"
|
|
||||||
>
|
|
||||||
Root
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -517,6 +517,104 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
event_task_columns: {
|
||||||
|
Row: {
|
||||||
|
color: string | null
|
||||||
|
created_at: string | null
|
||||||
|
event_id: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
color?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
event_id: string
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
color?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
event_id?: string
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "event_task_columns_event_id_fkey"
|
||||||
|
columns: ["event_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "events"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
event_tasks: {
|
||||||
|
Row: {
|
||||||
|
assignee_id: string | null
|
||||||
|
color: string | null
|
||||||
|
column_id: string
|
||||||
|
created_at: string | null
|
||||||
|
created_by: string | null
|
||||||
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
|
event_id: string
|
||||||
|
id: string
|
||||||
|
position: number
|
||||||
|
priority: string | null
|
||||||
|
title: string
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
assignee_id?: string | null
|
||||||
|
color?: string | null
|
||||||
|
column_id: string
|
||||||
|
created_at?: string | null
|
||||||
|
created_by?: string | null
|
||||||
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
event_id: string
|
||||||
|
id?: string
|
||||||
|
position?: number
|
||||||
|
priority?: string | null
|
||||||
|
title: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
assignee_id?: string | null
|
||||||
|
color?: string | null
|
||||||
|
column_id?: string
|
||||||
|
created_at?: string | null
|
||||||
|
created_by?: string | null
|
||||||
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
event_id?: string
|
||||||
|
id?: string
|
||||||
|
position?: number
|
||||||
|
priority?: string | null
|
||||||
|
title?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "event_tasks_column_id_fkey"
|
||||||
|
columns: ["column_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "event_task_columns"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "event_tasks_event_id_fkey"
|
||||||
|
columns: ["event_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "events"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
events: {
|
events: {
|
||||||
Row: {
|
Row: {
|
||||||
color: string | null
|
color: string | null
|
||||||
@@ -1433,15 +1531,15 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// ============================================================
|
// Convenience type aliases
|
||||||
// Convenience type aliases (used across the codebase)
|
export type Profile = Tables<'profiles'>;
|
||||||
// ============================================================
|
export type Organization = Tables<'organizations'>;
|
||||||
export type Profile = Tables<'profiles'>
|
export type Document = Tables<'documents'>;
|
||||||
export type Organization = Tables<'organizations'>
|
export type KanbanBoard = Tables<'kanban_boards'>;
|
||||||
export type Document = Tables<'documents'>
|
export type KanbanColumn = Tables<'kanban_columns'>;
|
||||||
export type KanbanBoard = Tables<'kanban_boards'>
|
export type KanbanCard = Tables<'kanban_cards'>;
|
||||||
export type KanbanColumn = Tables<'kanban_columns'>
|
export type CalendarEvent = Tables<'calendar_events'>;
|
||||||
export type KanbanCard = Tables<'kanban_cards'>
|
export type OrgRole = Tables<'org_roles'>;
|
||||||
export type CalendarEvent = Tables<'calendar_events'>
|
export type EventTaskColumn = Tables<'event_task_columns'>;
|
||||||
export type OrgRole = Tables<'org_roles'>
|
export type EventTask = Tables<'event_tasks'>;
|
||||||
export type MemberRole = string
|
export type MemberRole = string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page, navigating } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
@@ -147,8 +147,15 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
|
const navTo = $navigating?.to?.url.pathname;
|
||||||
|
if (navTo) return navTo.startsWith(href);
|
||||||
return $page.url.pathname.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>
|
</script>
|
||||||
|
|
||||||
<!-- Figma-matched layout: bg-background with gap-4 padding -->
|
<!-- Figma-matched layout: bg-background with gap-4 padding -->
|
||||||
@@ -230,7 +237,11 @@
|
|||||||
? 'opacity-0 max-w-0 overflow-hidden'
|
? 'opacity-0 max-w-0 overflow-hidden'
|
||||||
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
: '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">
|
<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}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
@@ -337,9 +348,7 @@
|
|||||||
class="flex items-center justify-center"
|
class="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Logo
|
<Logo
|
||||||
size={sidebarCollapsed ? "sm" : "md"}
|
size={sidebarCollapsed ? "sm" : "md"} />
|
||||||
showText={!sidebarCollapsed}
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMount, getContext } from "svelte";
|
import { onMount, getContext } from "svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { page } from "$app/state";
|
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 {
|
import {
|
||||||
MessageList,
|
MessageList,
|
||||||
MessageInput,
|
MessageInput,
|
||||||
@@ -51,13 +52,8 @@
|
|||||||
// Matrix state
|
// Matrix state
|
||||||
let matrixClient = $state<MatrixClient | null>(null);
|
let matrixClient = $state<MatrixClient | null>(null);
|
||||||
let isInitializing = $state(true);
|
let isInitializing = $state(true);
|
||||||
let showMatrixLogin = $state(false);
|
let showJoinScreen = $state(false);
|
||||||
|
let isProvisioning = $state(false);
|
||||||
// Matrix login form
|
|
||||||
let matrixHomeserver = $state("https://matrix.org");
|
|
||||||
let matrixUsername = $state("");
|
|
||||||
let matrixPassword = $state("");
|
|
||||||
let isLoggingIn = $state(false);
|
|
||||||
|
|
||||||
// Chat UI state
|
// Chat UI state
|
||||||
let showCreateRoomModal = $state(false);
|
let showCreateRoomModal = $state(false);
|
||||||
@@ -140,13 +136,13 @@
|
|||||||
deviceId: result.credentials.device_id,
|
deviceId: result.credentials.device_id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No stored credentials — show login form
|
// No stored credentials — show join screen
|
||||||
showMatrixLogin = true;
|
showJoinScreen = true;
|
||||||
isInitializing = false;
|
isInitializing = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load Matrix credentials:", e);
|
console.error("Failed to load Matrix credentials:", e);
|
||||||
showMatrixLogin = true;
|
showJoinScreen = true;
|
||||||
isInitializing = false;
|
isInitializing = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -169,8 +165,8 @@
|
|||||||
await ensureOrgSpace(credentials);
|
await ensureOrgSpace(credentials);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error("Failed to init Matrix client:", e);
|
console.error("Failed to init Matrix client:", e);
|
||||||
toasts.error("Failed to connect to chat. Please re-login.");
|
toasts.error(m.chat_join_error());
|
||||||
showMatrixLogin = true;
|
showJoinScreen = true;
|
||||||
} finally {
|
} finally {
|
||||||
isInitializing = false;
|
isInitializing = false;
|
||||||
}
|
}
|
||||||
@@ -204,41 +200,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMatrixLogin() {
|
async function handleJoinChat() {
|
||||||
if (!matrixUsername.trim() || !matrixPassword.trim()) {
|
isProvisioning = true;
|
||||||
toasts.error("Please enter username and password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoggingIn = true;
|
|
||||||
try {
|
try {
|
||||||
const { loginWithPassword } = await import("$lib/matrix");
|
const res = await fetch("/api/matrix-provision", {
|
||||||
const credentials = await loginWithPassword({
|
|
||||||
homeserverUrl: matrixHomeserver,
|
|
||||||
username: matrixUsername.trim(),
|
|
||||||
password: matrixPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save to Supabase
|
|
||||||
await fetch("/api/matrix-credentials", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ org_id: data.org.id }),
|
||||||
org_id: data.org.id,
|
|
||||||
homeserver_url: credentials.homeserverUrl,
|
|
||||||
matrix_user_id: credentials.userId,
|
|
||||||
access_token: credentials.accessToken,
|
|
||||||
device_id: credentials.deviceId,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
showMatrixLogin = false;
|
const result = await res.json();
|
||||||
await initFromCredentials(credentials);
|
if (!res.ok) throw new Error(result.error || "Provisioning failed");
|
||||||
toasts.success("Connected to chat!");
|
|
||||||
|
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) {
|
} catch (e: any) {
|
||||||
toasts.error(e.message || "Login failed");
|
toasts.error(e.message || m.chat_join_error());
|
||||||
} finally {
|
} finally {
|
||||||
isLoggingIn = false;
|
isProvisioning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +243,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
matrixClient = null;
|
matrixClient = null;
|
||||||
showMatrixLogin = true;
|
showJoinScreen = true;
|
||||||
auth.set({
|
auth.set({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -389,47 +377,37 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Matrix Login Modal -->
|
<!-- Join Chat consent screen -->
|
||||||
{#if showMatrixLogin}
|
{#if showJoinScreen}
|
||||||
<div class="h-full flex items-center justify-center">
|
<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">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md text-center">
|
||||||
<h2 class="font-heading text-body text-white mb-1">Connect to Chat</h2>
|
<span
|
||||||
<p class="text-body-sm text-light/50 mb-6">
|
class="material-symbols-rounded text-primary mb-4 inline-block"
|
||||||
Enter your Matrix credentials to enable messaging.
|
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>
|
</p>
|
||||||
|
<p class="text-body-sm text-light/40 mb-6">
|
||||||
<div class="space-y-4">
|
{m.chat_join_consent()}
|
||||||
<Input
|
</p>
|
||||||
label="Homeserver URL"
|
<Button
|
||||||
bind:value={matrixHomeserver}
|
variant="primary"
|
||||||
placeholder="https://matrix.org"
|
fullWidth
|
||||||
/>
|
onclick={handleJoinChat}
|
||||||
<Input
|
disabled={isProvisioning}
|
||||||
label="Username"
|
>
|
||||||
bind:value={matrixUsername}
|
{isProvisioning ? m.chat_joining() : m.chat_join_button()}
|
||||||
placeholder="@user:matrix.org"
|
</Button>
|
||||||
/>
|
<a
|
||||||
<div>
|
href="https://matrix.org"
|
||||||
<label class="block text-body-sm font-body text-light/60 mb-1">Password</label>
|
target="_blank"
|
||||||
<input
|
rel="noopener noreferrer"
|
||||||
type="password"
|
class="inline-block mt-4 text-[12px] text-light/30 hover:text-light/50 transition-colors"
|
||||||
bind:value={matrixPassword}
|
>
|
||||||
placeholder="Password"
|
{m.chat_join_learn_more()} →
|
||||||
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"
|
</a>
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") handleMatrixLogin();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
fullWidth
|
|
||||||
onclick={handleMatrixLogin}
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
>
|
|
||||||
{isLoggingIn ? "Connecting..." : "Connect"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
deleteCard,
|
deleteCard,
|
||||||
deleteColumn,
|
deleteColumn,
|
||||||
subscribeToBoard,
|
subscribeToBoard,
|
||||||
|
type RealtimeChangePayload,
|
||||||
|
type ColumnWithCards,
|
||||||
|
type BoardWithColumns,
|
||||||
} from "$lib/api/kanban";
|
} from "$lib/api/kanban";
|
||||||
import {
|
import {
|
||||||
getLockInfo,
|
getLockInfo,
|
||||||
@@ -24,8 +27,8 @@
|
|||||||
RealtimeChannel,
|
RealtimeChannel,
|
||||||
SupabaseClient,
|
SupabaseClient,
|
||||||
} from "@supabase/supabase-js";
|
} from "@supabase/supabase-js";
|
||||||
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
|
import type { Database, KanbanCard, KanbanColumn, Document } from "$lib/supabase/types";
|
||||||
import type { BoardWithColumns } from "$lib/api/kanban";
|
import { untrack } from "svelte";
|
||||||
|
|
||||||
const log = createLogger("page.file-viewer");
|
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;
|
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(
|
const channel = subscribeToBoard(
|
||||||
supabase,
|
supabase,
|
||||||
kanbanBoard.id,
|
boardId,
|
||||||
colIds,
|
colIds,
|
||||||
() => loadKanbanBoard(),
|
handleColumnRealtime,
|
||||||
() => loadKanbanBoard(),
|
handleCardRealtime,
|
||||||
);
|
);
|
||||||
realtimeChannel = channel;
|
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,
|
cardId: string,
|
||||||
toColumnId: string,
|
toColumnId: string,
|
||||||
toPosition: number,
|
toPosition: number,
|
||||||
) {
|
) {
|
||||||
try {
|
if (!kanbanBoard) return;
|
||||||
await moveCard(supabase, cardId, toColumnId, toPosition);
|
|
||||||
} catch (err) {
|
const fromColId = kanbanBoard.columns.find((c: ColumnWithCards) =>
|
||||||
log.error("Failed to move card", { error: err });
|
c.cards.some((card: KanbanCard) => card.id === cardId),
|
||||||
toasts.error("Failed to move card");
|
)?.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) {
|
function handleCardClick(card: KanbanCard) {
|
||||||
@@ -356,7 +471,7 @@
|
|||||||
for (const col of json.columns) {
|
for (const col of json.columns) {
|
||||||
// Check if column already exists by name
|
// Check if column already exists by name
|
||||||
let targetCol = kanbanBoard.columns.find(
|
let targetCol = kanbanBoard.columns.find(
|
||||||
(c) =>
|
(c: ColumnWithCards) =>
|
||||||
c.name.toLowerCase() ===
|
c.name.toLowerCase() ===
|
||||||
(col.name || "").toLowerCase(),
|
(col.name || "").toLowerCase(),
|
||||||
);
|
);
|
||||||
@@ -419,9 +534,9 @@
|
|||||||
if (!kanbanBoard) return;
|
if (!kanbanBoard) return;
|
||||||
const exportData = {
|
const exportData = {
|
||||||
board: kanbanBoard.name,
|
board: kanbanBoard.name,
|
||||||
columns: kanbanBoard.columns.map((col) => ({
|
columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({
|
||||||
name: col.name,
|
name: col.name,
|
||||||
cards: col.cards.map((card) => ({
|
cards: col.cards.map((card: KanbanCard) => ({
|
||||||
title: card.title,
|
title: card.title,
|
||||||
description: card.description,
|
description: card.description,
|
||||||
priority: card.priority,
|
priority: card.priority,
|
||||||
@@ -545,9 +660,9 @@
|
|||||||
if (kanbanBoard) {
|
if (kanbanBoard) {
|
||||||
kanbanBoard = {
|
kanbanBoard = {
|
||||||
...kanbanBoard,
|
...kanbanBoard,
|
||||||
columns: kanbanBoard.columns.map((col) => ({
|
columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({
|
||||||
...col,
|
...col,
|
||||||
cards: col.cards.map((c) =>
|
cards: col.cards.map((c: KanbanCard) =>
|
||||||
c.id === updatedCard.id ? updatedCard : c,
|
c.id === updatedCard.id ? updatedCard : c,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page, navigating } from "$app/stores";
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { Avatar } from "$lib/components/ui";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
|
import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
|
||||||
@@ -68,10 +68,23 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function isModuleActive(href: string, exact?: boolean): boolean {
|
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;
|
if (exact) return $page.url.pathname === href;
|
||||||
return $page.url.pathname.startsWith(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 {
|
function getStatusColor(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
planning: "bg-amber-400",
|
planning: "bg-amber-400",
|
||||||
@@ -143,7 +156,10 @@
|
|||||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
>{mod.icon}</span
|
>{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>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { fetchTaskColumns } from '$lib/api/event-tasks';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent }) => {
|
const log = createLogger('page.event-tasks');
|
||||||
return await parent();
|
|
||||||
|
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">
|
<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";
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
const log = createLogger("page.event-tasks");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
event: { name: string; slug: string };
|
userRole: string;
|
||||||
|
event: Event;
|
||||||
|
taskColumns: TaskColumnWithTasks[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{m.events_mod_tasks()} | {data.event.name} | {data.org.name}</title>
|
<title>{m.events_mod_tasks()} | {data.event.name} | {data.org.name}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
|
<div class="flex flex-col h-full">
|
||||||
<span
|
<!-- Toolbar -->
|
||||||
class="material-symbols-rounded mb-4"
|
<div class="flex items-center justify-between px-4 py-3 border-b border-light/5">
|
||||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
<h2 class="text-h4 font-heading text-white">{m.events_mod_tasks()}</h2>
|
||||||
>task_alt</span
|
</div>
|
||||||
>
|
|
||||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_tasks()}</h2>
|
<!-- Board -->
|
||||||
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
<div class="flex-1 overflow-hidden p-4">
|
||||||
{m.events_mod_tasks_desc()}
|
{#if boardColumns.length > 0}
|
||||||
</p>
|
<KanbanBoard
|
||||||
<span
|
columns={boardColumns}
|
||||||
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
|
onCardMove={handleCardMove}
|
||||||
>{m.module_coming_soon()}</span
|
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>
|
</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 cardModalMode = $state<"edit" | "create">("edit");
|
||||||
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
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) {
|
async function loadBoard(boardId: string) {
|
||||||
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
||||||
}
|
}
|
||||||
@@ -118,6 +121,12 @@
|
|||||||
if (!selectedBoard) return;
|
if (!selectedBoard) return;
|
||||||
const { event } = payload;
|
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") {
|
if (event === "INSERT") {
|
||||||
const card = payload.new;
|
const card = payload.new;
|
||||||
if (!card.column_id) return;
|
if (!card.column_id) return;
|
||||||
@@ -412,37 +421,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCardMove(
|
function handleCardMove(
|
||||||
cardId: string,
|
cardId: string,
|
||||||
toColumnId: string,
|
toColumnId: string,
|
||||||
toPosition: number,
|
toPosition: number,
|
||||||
) {
|
) {
|
||||||
if (!selectedBoard) return;
|
if (!selectedBoard) return;
|
||||||
|
|
||||||
// Optimistic UI update - move card immediately
|
const fromColId = selectedBoard.columns.find((c) =>
|
||||||
const fromColumn = selectedBoard.columns.find((c) =>
|
|
||||||
c.cards.some((card) => card.id === cardId),
|
c.cards.some((card) => card.id === cardId),
|
||||||
);
|
)?.id;
|
||||||
const toColumn = selectedBoard.columns.find((c) => c.id === toColumnId);
|
if (!fromColId) return;
|
||||||
|
|
||||||
if (!fromColumn || !toColumn) return;
|
// Build fully immutable new columns so Svelte sees fresh references instantly
|
||||||
|
let movedCard: KanbanCard | undefined;
|
||||||
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
|
const newColumns = selectedBoard.columns.map((col) => {
|
||||||
if (cardIndex === -1) return;
|
if (col.id === fromColId && fromColId !== toColumnId) {
|
||||||
|
const filtered = col.cards.filter((c) => {
|
||||||
const [movedCard] = fromColumn.cards.splice(cardIndex, 1);
|
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
|
||||||
movedCard.column_id = toColumnId;
|
return true;
|
||||||
toColumn.cards.splice(toPosition, 0, movedCard);
|
});
|
||||||
|
return { ...col, cards: filtered };
|
||||||
// Trigger reactivity
|
}
|
||||||
selectedBoard = { ...selectedBoard };
|
if (col.id === toColumnId && fromColId !== toColumnId) {
|
||||||
|
const cards = [...col.cards];
|
||||||
// Persist to database in background
|
return { ...col, cards, _insertAt: toPosition } as typeof col & { _insertAt: number };
|
||||||
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
|
}
|
||||||
log.error("Failed to persist card move", { error: err });
|
if (col.id === fromColId && fromColId === toColumnId) {
|
||||||
// Reload to sync state on error
|
const card = col.cards.find((c) => c.id === cardId);
|
||||||
loadBoard(selectedBoard!.id);
|
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) {
|
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 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
99
supabase/migrations/025_event_tasks.sql
Normal file
99
supabase/migrations/025_event_tasks.sql
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
-- Event Tasks: kanban-style task management scoped to events
|
||||||
|
-- Uses the same KanbanBoard component but with event-specific tables
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. Task columns: kanban columns per event
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE event_task_columns (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
color TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_event_task_columns_event ON event_task_columns(event_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. Tasks: cards within columns
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE event_tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
column_id UUID NOT NULL REFERENCES event_task_columns(id) ON DELETE CASCADE,
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
priority TEXT CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||||
|
due_date DATE,
|
||||||
|
color TEXT,
|
||||||
|
assignee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_event_tasks_column ON event_tasks(column_id);
|
||||||
|
CREATE INDEX idx_event_tasks_event ON event_tasks(event_id);
|
||||||
|
CREATE INDEX idx_event_tasks_assignee ON event_tasks(assignee_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. RLS policies
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE event_task_columns ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE event_tasks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Task columns: org members can view, editors+ can manage
|
||||||
|
CREATE POLICY "Org members can view event task columns" ON event_task_columns FOR SELECT
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM events e
|
||||||
|
JOIN org_members om ON e.org_id = om.org_id
|
||||||
|
WHERE e.id = event_task_columns.event_id AND om.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage event task columns" ON event_task_columns FOR ALL
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM events e
|
||||||
|
JOIN org_members om ON e.org_id = om.org_id
|
||||||
|
WHERE e.id = event_task_columns.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Tasks: org members can view, editors+ can manage
|
||||||
|
CREATE POLICY "Org members can view event tasks" ON event_tasks FOR SELECT
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM events e
|
||||||
|
JOIN org_members om ON e.org_id = om.org_id
|
||||||
|
WHERE e.id = event_tasks.event_id AND om.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage event tasks" ON event_tasks FOR ALL
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM events e
|
||||||
|
JOIN org_members om ON e.org_id = om.org_id
|
||||||
|
WHERE e.id = event_tasks.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. Enable realtime
|
||||||
|
-- ============================================================
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE event_task_columns;
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE event_tasks;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. Auto-seed default columns when a new event is created
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.seed_event_task_columns()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.event_task_columns (event_id, name, position) VALUES
|
||||||
|
(NEW.id, 'To Do', 0),
|
||||||
|
(NEW.id, 'In Progress', 1),
|
||||||
|
(NEW.id, 'Done', 2);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_event_created_seed_task_columns
|
||||||
|
AFTER INSERT ON events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.seed_event_task_columns();
|
||||||
12
synapse/docker-compose.yml
Normal file
12
synapse/docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
synapse:
|
||||||
|
image: matrixdotorg/synapse:latest
|
||||||
|
container_name: root-org-synapse
|
||||||
|
ports:
|
||||||
|
- "8008:8008"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- SYNAPSE_SERVER_NAME=localhost
|
||||||
|
- SYNAPSE_REPORT_STATS=no
|
||||||
|
restart: unless-stopped
|
||||||
174
tests/e2e/kanban-perf.spec.ts
Normal file
174
tests/e2e/kanban-perf.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, TEST_ORG_SLUG } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures the REAL end-to-end latency a user experiences when dragging
|
||||||
|
* a kanban card from one column to another using native mouse events.
|
||||||
|
*
|
||||||
|
* Uses MutationObserver set up BEFORE the drag starts, then performs
|
||||||
|
* a real mouse drag-and-drop sequence. Measures from mouse-up to
|
||||||
|
* card appearing in the target column DOM.
|
||||||
|
*/
|
||||||
|
test.describe('Kanban card move latency', () => {
|
||||||
|
test('real drag-drop: card should appear in target column fast', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`/${TEST_ORG_SLUG}/kanban`, { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
// Click into the first board if we're on the board selector
|
||||||
|
const anyBoard = page.locator('text=/board/i').first();
|
||||||
|
if (await anyBoard.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
await anyBoard.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for columns
|
||||||
|
await page.waitForSelector('[data-column-id]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Ensure there's a card to move — check first column
|
||||||
|
const firstCard = page.locator('[data-column-id]').first().locator('[data-card-id]').first();
|
||||||
|
if (!(await firstCard.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||||
|
const addBtn = page.locator('[data-column-id]').first().getByRole('button', { name: /add card/i });
|
||||||
|
if (await addBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await addBtn.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const titleInput = page.locator('input[placeholder="Card title"]');
|
||||||
|
await titleInput.fill('Perf Test Card');
|
||||||
|
await page.getByRole('button', { name: 'Add Card', exact: true }).click();
|
||||||
|
await page.waitForSelector('[data-card-id]', { timeout: 5000 });
|
||||||
|
} else {
|
||||||
|
console.log('Cannot create card, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather IDs
|
||||||
|
const cardId = await page.locator('[data-column-id]').first().locator('[data-card-id]').first().getAttribute('data-card-id');
|
||||||
|
const columns = page.locator('[data-column-id]');
|
||||||
|
if ((await columns.count()) < 2) { console.log('Need 2+ columns'); return; }
|
||||||
|
const dstColId = await columns.nth(1).getAttribute('data-column-id');
|
||||||
|
|
||||||
|
console.log(`Card: ${cardId}`);
|
||||||
|
console.log(`Target column: ${dstColId}`);
|
||||||
|
|
||||||
|
// Set up a MutationObserver on the target column BEFORE the drag starts
|
||||||
|
// This will record the exact timestamp when the card DOM node appears
|
||||||
|
await page.evaluate(({ cardId, dstColId }) => {
|
||||||
|
const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`);
|
||||||
|
if (!dstCol) return;
|
||||||
|
|
||||||
|
(window as any).__cardAppearedAt = 0;
|
||||||
|
(window as any).__mouseUpAt = 0;
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if ((window as any).__cardAppearedAt > 0) return;
|
||||||
|
const found = dstCol.querySelector(`[data-card-id="${cardId}"]`);
|
||||||
|
if (found) {
|
||||||
|
(window as any).__cardAppearedAt = performance.now();
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(dstCol, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Also hook into mouseup to record the exact drop moment
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if ((window as any).__mouseUpAt === 0) {
|
||||||
|
(window as any).__mouseUpAt = performance.now();
|
||||||
|
}
|
||||||
|
}, { once: true, capture: true });
|
||||||
|
}, { cardId, dstColId });
|
||||||
|
|
||||||
|
// Get bounding boxes
|
||||||
|
const card = page.locator('[data-column-id]').first().locator('[data-card-id]').first();
|
||||||
|
const cardBox = await card.boundingBox();
|
||||||
|
const targetBox = await columns.nth(1).boundingBox();
|
||||||
|
if (!cardBox || !targetBox) { console.log('No bounding boxes'); return; }
|
||||||
|
|
||||||
|
const srcX = cardBox.x + cardBox.width / 2;
|
||||||
|
const srcY = cardBox.y + cardBox.height / 2;
|
||||||
|
const dstX = targetBox.x + targetBox.width / 2;
|
||||||
|
const dstY = targetBox.y + 100;
|
||||||
|
|
||||||
|
// Perform REAL mouse drag
|
||||||
|
await page.mouse.move(srcX, srcY);
|
||||||
|
await page.mouse.down();
|
||||||
|
// Small move to trigger dragstart
|
||||||
|
await page.mouse.move(srcX + 5, srcY, { steps: 2 });
|
||||||
|
await page.waitForTimeout(50); // Let browser register the drag
|
||||||
|
// Move to target
|
||||||
|
await page.mouse.move(dstX, dstY, { steps: 3 });
|
||||||
|
await page.waitForTimeout(50); // Let dragover register
|
||||||
|
// Drop
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Wait a bit for everything to settle
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Read the timestamps
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
return {
|
||||||
|
mouseUpAt: (window as any).__mouseUpAt as number,
|
||||||
|
cardAppearedAt: (window as any).__cardAppearedAt as number,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropToRender = result.cardAppearedAt > 0 && result.mouseUpAt > 0
|
||||||
|
? result.cardAppearedAt - result.mouseUpAt
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
console.log(`\n========================================`);
|
||||||
|
console.log(` mouseup timestamp: ${result.mouseUpAt.toFixed(1)}`);
|
||||||
|
console.log(` card appeared at: ${result.cardAppearedAt.toFixed(1)}`);
|
||||||
|
console.log(` DROP → RENDER LATENCY: ${dropToRender.toFixed(1)}ms`);
|
||||||
|
if (dropToRender < 0) {
|
||||||
|
console.log(' ⚠️ Card never appeared or mouseup not captured');
|
||||||
|
console.log(' (HTML5 drag may not have fired — trying synthetic fallback)');
|
||||||
|
} else if (dropToRender < 20) {
|
||||||
|
console.log(' ✅ INSTANT (<20ms)');
|
||||||
|
} else if (dropToRender < 50) {
|
||||||
|
console.log(' ✅ VERY FAST (<50ms)');
|
||||||
|
} else if (dropToRender < 100) {
|
||||||
|
console.log(' ⚠️ PERCEPTIBLE (50-100ms)');
|
||||||
|
} else if (dropToRender < 500) {
|
||||||
|
console.log(' ❌ SLOW (100-500ms)');
|
||||||
|
} else {
|
||||||
|
console.log(' ❌ VERY SLOW (>500ms)');
|
||||||
|
}
|
||||||
|
console.log(`========================================\n`);
|
||||||
|
|
||||||
|
// If native drag didn't work, fall back to synthetic events to at least measure Svelte
|
||||||
|
if (dropToRender < 0) {
|
||||||
|
console.log('Falling back to synthetic drag events...');
|
||||||
|
const synthLatency = await page.evaluate(({ cardId, dstColId }) => {
|
||||||
|
return new Promise<number>((resolve) => {
|
||||||
|
const cardEl = document.querySelector(`[data-card-id="${cardId}"]`);
|
||||||
|
const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`);
|
||||||
|
if (!cardEl || !dstCol) { resolve(-1); return; }
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const found = dstCol.querySelector(`[data-card-id="${cardId}"]`);
|
||||||
|
if (found) { observer.disconnect(); resolve(performance.now() - t0); }
|
||||||
|
});
|
||||||
|
observer.observe(dstCol, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('text/plain', cardId!);
|
||||||
|
cardEl.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||||
|
dstCol.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||||
|
const t0 = performance.now();
|
||||||
|
dstCol.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
const found = dstCol.querySelector(`[data-card-id="${cardId}"]`);
|
||||||
|
resolve(found ? performance.now() - t0 : 9999);
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}, { cardId, dstColId });
|
||||||
|
console.log(` Synthetic drop→render: ${synthLatency.toFixed(1)}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify card moved
|
||||||
|
const cardInTarget = columns.nth(1).locator(`[data-card-id="${cardId}"]`);
|
||||||
|
const visible = await cardInTarget.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
console.log(` Card visible in target: ${visible}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user