Files
root-org/src/lib/components/ui/VirtualList.svelte
AlacrisDevs d1ce5d0951 feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)
- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps
- Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/
- Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/
- Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList
- Copy utils: emojiData, twemoji, twemojiGlobal
- Replace lucide-svelte with Material Symbols in SyncRecoveryBanner
- Extend Avatar with xs size and status indicator prop
- Fix ui.ts store conflict: re-export toasts from toast.svelte.ts
- Add migration 020_matrix_credentials for storing Matrix tokens per user/org
- Add /api/matrix-credentials endpoint (GET/POST/DELETE)
- Create [orgSlug]/chat page with Matrix login form + full chat UI
- Add Chat to sidebar navigation
2026-02-07 01:44:06 +02:00

115 lines
3.0 KiB
Svelte

<script lang="ts" generics="T">
import { onMount, tick } from "svelte";
interface Props {
items: T[];
itemHeight: number;
overscan?: number;
containerClass?: string;
getKey: (item: T, index: number) => string;
children: import("svelte").Snippet<[T, number]>;
onScrollTop?: () => void;
onScrollBottom?: () => void;
}
let {
items,
itemHeight,
overscan = 5,
containerClass = "",
getKey,
children,
onScrollTop,
onScrollBottom,
}: Props = $props();
let containerRef: HTMLDivElement | null = $state(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
// Calculate visible range
const visibleRange = $derived(() => {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
const endIndex = Math.min(items.length, startIndex + visibleCount);
return { startIndex, endIndex };
});
// Get visible items with their indices
const visibleItems = $derived(() => {
const { startIndex, endIndex } = visibleRange();
return items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
}));
});
// Total height of the list
const totalHeight = $derived(items.length * itemHeight);
// Offset for visible items
const offsetY = $derived(visibleRange().startIndex * itemHeight);
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
scrollTop = target.scrollTop;
// Check for scroll to top (load more)
if (target.scrollTop < 100 && onScrollTop) {
onScrollTop();
}
// Check for scroll to bottom
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceToBottom < 100 && onScrollBottom) {
onScrollBottom();
}
}
function updateContainerHeight() {
if (containerRef) {
containerHeight = containerRef.clientHeight;
}
}
onMount(() => {
updateContainerHeight();
const resizeObserver = new ResizeObserver(updateContainerHeight);
if (containerRef) {
resizeObserver.observe(containerRef);
}
return () => resizeObserver.disconnect();
});
// Scroll to bottom
export async function scrollToBottom() {
await tick();
if (containerRef) {
containerRef.scrollTop = containerRef.scrollHeight;
}
}
// Scroll to specific index
export function scrollToIndex(index: number) {
if (containerRef) {
containerRef.scrollTop = index * itemHeight;
}
}
</script>
<div
bind:this={containerRef}
class="overflow-y-auto {containerClass}"
onscroll={handleScroll}
>
<div style="height: {totalHeight}px; position: relative;">
<div style="transform: translateY({offsetY}px);">
{#each visibleItems() as { item, index } (getKey(item, index))}
<div style="height: {itemHeight}px;">
{@render children(item, index)}
</div>
{/each}
</div>
</div>
</div>