Phase 5: Real-time updates via Socket.IO
Wire Socket.IO broadcast from all write API routes so board changes propagate instantly to other connected clients. Add session-based auth on socket connections, in-memory presence tracking, and a presence UI in the board header showing who else is viewing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { onMount } from 'svelte';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
|
||||
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -22,9 +24,136 @@
|
||||
let selectedCard = $state<any>(null);
|
||||
let editingColumnId = $state<string | null>(null);
|
||||
let editingColumnTitle = $state('');
|
||||
let viewingUsers = $state<any[]>([]);
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
// ─── Socket.IO header for self-dedup ─────────────
|
||||
function socketHeaders(): Record<string, string> {
|
||||
const sid = getSocketId();
|
||||
return sid ? { 'X-Socket-ID': sid } : {};
|
||||
}
|
||||
|
||||
// ─── Real-Time Event Handlers ────────────────────
|
||||
function isSelf(d: any) {
|
||||
return d?.socketId && d.socketId === getSocketId();
|
||||
}
|
||||
|
||||
function onColumnCreated(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
if (!columns.find((c: any) => c.id === d.column.id)) {
|
||||
columns = [...columns, { ...d.column, cards: d.column.cards ?? [] }];
|
||||
}
|
||||
}
|
||||
|
||||
function onColumnUpdated(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
const col = columns.find((c: any) => c.id === d.column.id);
|
||||
if (col) {
|
||||
col.title = d.column.title;
|
||||
col.position = d.column.position;
|
||||
}
|
||||
}
|
||||
|
||||
function onColumnDeleted(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
columns = columns.filter((c: any) => c.id !== d.columnId);
|
||||
}
|
||||
|
||||
function onCardCreated(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
const col = columns.find((c: any) => c.id === d.columnId);
|
||||
if (col && !col.cards.find((c: any) => c.id === d.card.id)) {
|
||||
col.cards = [...col.cards, d.card];
|
||||
}
|
||||
}
|
||||
|
||||
async function onCardUpdated(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
// Re-fetch card from API to get full updated data
|
||||
const colId = d.columnId || d.card?.columnId;
|
||||
const cardId = d.cardId || d.card?.id;
|
||||
if (!colId || !cardId) return;
|
||||
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`);
|
||||
if (!res.ok) return;
|
||||
const { card: freshCard } = await res.json();
|
||||
|
||||
// The card might have moved columns — remove from old location first
|
||||
for (const col of columns) {
|
||||
const idx = col.cards.findIndex((c: any) => c.id === cardId);
|
||||
if (idx !== -1) {
|
||||
if (col.id === freshCard.columnId) {
|
||||
// Same column — update in-place
|
||||
col.cards[idx] = { ...col.cards[idx], ...freshCard };
|
||||
} else {
|
||||
// Moved to a different column — remove from old
|
||||
col.cards = col.cards.filter((c: any) => c.id !== cardId);
|
||||
// Add to new column
|
||||
const newCol = columns.find((c: any) => c.id === freshCard.columnId);
|
||||
if (newCol) newCol.cards = [...newCol.cards, freshCard];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected card if it's the one being viewed
|
||||
if (selectedCard?.id === cardId) {
|
||||
selectedCard = { ...selectedCard, ...freshCard };
|
||||
}
|
||||
}
|
||||
|
||||
function onCardDeleted(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
const col = columns.find((c: any) => c.id === d.columnId);
|
||||
if (col) col.cards = col.cards.filter((c: any) => c.id !== d.cardId);
|
||||
if (selectedCard?.id === d.cardId) selectedCard = null;
|
||||
}
|
||||
|
||||
function onBoardUpdated(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
if (d.board) {
|
||||
data.board.title = d.board.title;
|
||||
data.board.visibility = d.board.visibility;
|
||||
}
|
||||
}
|
||||
|
||||
function onBoardDeleted(d: any) {
|
||||
if (isSelf(d)) return;
|
||||
window.location.href = '/boards';
|
||||
}
|
||||
|
||||
function onPresenceUpdate(users: any[]) {
|
||||
viewingUsers = users;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
joinBoard(data.board.id);
|
||||
|
||||
onBoardEvent('column:created', onColumnCreated);
|
||||
onBoardEvent('column:updated', onColumnUpdated);
|
||||
onBoardEvent('column:deleted', onColumnDeleted);
|
||||
onBoardEvent('card:created', onCardCreated);
|
||||
onBoardEvent('card:updated', onCardUpdated);
|
||||
onBoardEvent('card:deleted', onCardDeleted);
|
||||
onBoardEvent('board:updated', onBoardUpdated);
|
||||
onBoardEvent('board:deleted', onBoardDeleted);
|
||||
onBoardEvent('presence:update', onPresenceUpdate);
|
||||
|
||||
return () => {
|
||||
leaveBoard(data.board.id);
|
||||
offBoardEvent('column:created', onColumnCreated);
|
||||
offBoardEvent('column:updated', onColumnUpdated);
|
||||
offBoardEvent('column:deleted', onColumnDeleted);
|
||||
offBoardEvent('card:created', onCardCreated);
|
||||
offBoardEvent('card:updated', onCardUpdated);
|
||||
offBoardEvent('card:deleted', onCardDeleted);
|
||||
offBoardEvent('board:updated', onBoardUpdated);
|
||||
offBoardEvent('board:deleted', onBoardDeleted);
|
||||
offBoardEvent('presence:update', onPresenceUpdate);
|
||||
};
|
||||
});
|
||||
|
||||
function getChecklistProgress(card: any): { done: number; total: number } | null {
|
||||
if (!card.checklists || card.checklists.length === 0) return null;
|
||||
let done = 0, total = 0;
|
||||
@@ -49,7 +178,7 @@
|
||||
col.position = newPos;
|
||||
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||
body: JSON.stringify({ position: newPos })
|
||||
});
|
||||
}
|
||||
@@ -75,7 +204,7 @@
|
||||
|
||||
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||
body: JSON.stringify({ columnId, position: newPos })
|
||||
});
|
||||
}
|
||||
@@ -86,7 +215,7 @@
|
||||
if (!addingColumnTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||
body: JSON.stringify({ title: addingColumnTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -102,7 +231,7 @@
|
||||
if (!newCardTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||
body: JSON.stringify({ title: newCardTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -117,7 +246,8 @@
|
||||
// ─── Delete Column ───────────────────────────────
|
||||
async function deleteColumn(columnId: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: { ...socketHeaders() }
|
||||
});
|
||||
if (res.ok) {
|
||||
columns = columns.filter((c: any) => c.id !== columnId);
|
||||
@@ -129,7 +259,7 @@
|
||||
if (!editingColumnTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||
body: JSON.stringify({ title: editingColumnTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -142,7 +272,8 @@
|
||||
// ─── Delete Card ─────────────────────────────────
|
||||
async function deleteCard(columnId: string, cardId: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: { ...socketHeaders() }
|
||||
});
|
||||
if (res.ok) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
@@ -202,6 +333,22 @@
|
||||
>
|
||||
{data.board.members.length} {data.board.members.length === 1 ? 'member' : 'members'}
|
||||
</a>
|
||||
|
||||
{#if viewingUsers.filter((u) => u.id !== data.user?.id).length > 0}
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-400">Also here:</span>
|
||||
<div class="flex -space-x-1.5">
|
||||
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-emerald-500 text-white text-[11px] flex items-center justify-center border-2 border-white"
|
||||
title={viewer.name}
|
||||
>
|
||||
{viewer.name[0].toUpperCase()}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Columns container -->
|
||||
|
||||
Reference in New Issue
Block a user