7559102479
- Add dark mode with theme toggle, localStorage/cookie persistence, anti-FOUC script - Add toast notification system with auto-dismiss and color-coded types - Add api() fetch wrapper with auto JSON, X-Socket-ID, and error toasts - Add keyboard shortcuts (/ search, b boards, ? help, n new card, Esc close) - Add error page with status code and navigation - Add focus trap utility and apply to CardModal with full ARIA dialog support - Add hamburger menu for mobile, responsive columns, fullscreen card modal on mobile - Add aria-labels to icon-only buttons and DnD live region - Migrate all ~30 fetch calls to api() wrapper with success toasts - Add dark: Tailwind variants to all 20+ pages and components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
5.5 KiB
Svelte
157 lines
5.5 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
|
|
import { api } from '$lib/utils/api.js';
|
|
|
|
type Props = {
|
|
boardId: string;
|
|
cardId?: string;
|
|
columnId?: string;
|
|
};
|
|
|
|
let { boardId, cardId, columnId }: Props = $props();
|
|
|
|
let activities = $state<any[]>([]);
|
|
let nextCursor = $state<string | null>(null);
|
|
let loading = $state(true);
|
|
let loadingMore = $state(false);
|
|
|
|
function formatAction(a: any): string {
|
|
const meta = a.metadata || {};
|
|
switch (a.action) {
|
|
case 'board.updated': return 'updated the board';
|
|
case 'board.deleted': return 'deleted the board';
|
|
case 'column.created': return `created column "${meta.title}"`;
|
|
case 'column.updated': return `renamed column to "${meta.title}"`;
|
|
case 'column.deleted': return `deleted column "${meta.title}"`;
|
|
case 'card.created': return `created card "${meta.title}"`;
|
|
case 'card.moved': return `moved card from "${meta.from}" to "${meta.to}"`;
|
|
case 'card.updated': return 'updated card';
|
|
case 'card.deleted': return `deleted card "${meta.title}"`;
|
|
case 'card.archived': return `archived card "${meta.title}"`;
|
|
case 'comment.added': return 'added a comment';
|
|
case 'comment.deleted': return 'deleted a comment';
|
|
case 'member.added': return `added ${meta.member} to the board`;
|
|
case 'member.removed': return `removed ${meta.member} from the board`;
|
|
case 'member.updated': return `changed ${meta.member}'s role to ${meta.to}`;
|
|
case 'assignee.added': return `assigned ${meta.assignee}`;
|
|
case 'assignee.removed': return `unassigned ${meta.assignee}`;
|
|
case 'label.added': return `added label "${meta.tag}"`;
|
|
case 'label.removed': return `removed label "${meta.tag}"`;
|
|
case 'checklist.created': return `created checklist "${meta.title}"`;
|
|
case 'checklist.deleted': return `deleted checklist "${meta.title}"`;
|
|
case 'checklist_item.added': return `added "${meta.title}" to checklist`;
|
|
case 'checklist_item.toggled': return meta.completed ? `completed "${meta.title}"` : `unchecked "${meta.title}"`;
|
|
case 'attachment.added': return `attached "${meta.filename}"`;
|
|
case 'attachment.deleted': return `removed attachment "${meta.filename}"`;
|
|
default: return a.action;
|
|
}
|
|
}
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
if (seconds < 60) return 'just now';
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return new Date(dateStr).toLocaleDateString();
|
|
}
|
|
|
|
function buildUrl(): string {
|
|
if (cardId && columnId) {
|
|
return `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/activity`;
|
|
}
|
|
return `/api/boards/${boardId}/activity`;
|
|
}
|
|
|
|
async function fetchActivities(cursor?: string | null) {
|
|
const url = new URL(buildUrl(), window.location.origin);
|
|
url.searchParams.set('limit', '20');
|
|
if (cursor) url.searchParams.set('cursor', cursor);
|
|
|
|
const { ok, data } = await api(url.toString());
|
|
if (!ok) return;
|
|
return data;
|
|
}
|
|
|
|
async function loadMore() {
|
|
loadingMore = true;
|
|
const data = await fetchActivities(nextCursor);
|
|
if (data) {
|
|
activities = [...activities, ...data.activities];
|
|
nextCursor = data.nextCursor;
|
|
}
|
|
loadingMore = false;
|
|
}
|
|
|
|
function onActivityNew(d: any) {
|
|
// If we're showing card-specific activity and this is for a different card, ignore
|
|
if (cardId && d.cardId && d.cardId !== cardId) return;
|
|
|
|
// Re-fetch latest to get the new entry
|
|
fetchActivities().then((data) => {
|
|
if (data) {
|
|
// Merge: prepend new ones that aren't already in our list
|
|
const existingIds = new Set(activities.map((a) => a.id));
|
|
const newOnes = data.activities.filter((a: any) => !existingIds.has(a.id));
|
|
activities = [...newOnes, ...activities];
|
|
}
|
|
});
|
|
}
|
|
|
|
onMount(async () => {
|
|
const data = await fetchActivities();
|
|
if (data) {
|
|
activities = data.activities;
|
|
nextCursor = data.nextCursor;
|
|
}
|
|
loading = false;
|
|
|
|
onBoardEvent('activity:new', onActivityNew);
|
|
return () => offBoardEvent('activity:new', onActivityNew);
|
|
});
|
|
</script>
|
|
|
|
<div class="space-y-3">
|
|
{#if loading}
|
|
<div class="animate-pulse space-y-2">
|
|
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
|
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
|
|
</div>
|
|
{:else if activities.length === 0}
|
|
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">No activity yet</p>
|
|
{:else}
|
|
{#each activities as activity (activity.id)}
|
|
<div class="flex gap-2 text-sm">
|
|
<div
|
|
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center flex-shrink-0 mt-0.5"
|
|
title={activity.user.name}
|
|
>
|
|
{activity.user.name[0].toUpperCase()}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="text-gray-700 dark:text-gray-300 leading-snug">
|
|
<span class="font-medium">{activity.user.name}</span>
|
|
{' '}{formatAction(activity)}
|
|
</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{timeAgo(activity.createdAt)}</p>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if nextCursor}
|
|
<button
|
|
onclick={loadMore}
|
|
disabled={loadingMore}
|
|
class="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 py-2 transition"
|
|
>
|
|
{loadingMore ? 'Loading...' : 'Load more'}
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|