Files
windmill/frontend/src/lib/components/InfiniteList.svelte
Guilhem c95e6f7354 fix(frontend): improve ai chat ui (#7648)
* update chat to brand guidelines

* remove useless footer

* restore conversation count

* nit

* nit
2026-01-21 19:38:28 +00:00

252 lines
5.7 KiB
Svelte

<script lang="ts">
import DataTable from './table/DataTable.svelte'
import { createEventDispatcher } from 'svelte'
import { Row } from './table/index'
import { twMerge } from 'tailwind-merge'
const dispatch = createEventDispatcher()
interface Props {
loading?: boolean
items?: any[] | undefined
selectedItemId?: any | undefined
isEmpty?: boolean
length?: number
rounded?: boolean
noBorder?: boolean
extraRowClasses?: { bgSelected: string; bgHover: string; class: string }
neverShowLoader?: boolean
preventXOverflow?: boolean
customRow?: import('svelte').Snippet<[any]>
columns?: import('svelte').Snippet
extra_row?: import('svelte').Snippet<[any]>
children?: import('svelte').Snippet<[any]>
empty?: import('svelte').Snippet<[any]>
}
let {
loading = $bindable(false),
items = $bindable(undefined),
selectedItemId = $bindable(undefined),
isEmpty = $bindable(true),
length = $bindable(0),
rounded = true,
noBorder = false,
extraRowClasses = {
bgSelected: '',
bgHover: '',
class: ''
},
neverShowLoader = false,
preventXOverflow = false,
customRow,
columns,
extra_row,
children,
empty
}: Props = $props()
const perPage = 20
let hasMore = $state(false)
let page = 1
let hasAlreadyFailed = false
let hovered: any | undefined = $state(undefined)
let initLoad = false
let loadInputs: ((page: number, perPage: number) => Promise<any[]>) | undefined = undefined
let deleteItemFn: ((id: any) => Promise<any>) | undefined = undefined
export function reset() {
items = undefined
loading = false
initLoad = false
length = 0
hasMore = false
page = 1
}
let loadingMore = $state(false)
export async function loadData(loadOption: 'refresh' | 'forceRefresh' | 'loadMore' = 'loadMore') {
// console.log('loadData', loadOption, length, items?.length)
if (!loadInputs) return
if (loadOption == 'loadMore') {
if (loadingMore || loading) return
loadingMore = true
}
loading = true
hasMore = length === perPage * page
if (hasMore && loadOption === 'loadMore') {
page++
}
try {
const newItems = await loadInputs(1, page * perPage)
if (
loadOption === 'refresh' &&
items &&
items?.length > 0 &&
newItems.length === items?.length &&
newItems.every((i, index) => i.id === items?.[index]?.id)
) {
return
}
const existingIds = new Set(items?.map((i) => i.id) || [])
items = newItems.map((item) => ({
...item,
isNew: initLoad && !existingIds.has(item.id)
}))
setTimeout(() => {
if (items) {
items = items.map((item) => ({
...item,
isNew: false
}))
}
}, 2000)
if (items) {
page = Math.ceil(items.length / perPage)
hasMore = items.length === perPage * page
if (hasMore) {
const potentialNewItems = await loadInputs(page + 1, perPage)
hasMore = potentialNewItems.length > 0
}
initLoad = true
isEmpty = items.length === 0
length = items.length
}
} catch (err) {
console.error(err)
if (hasAlreadyFailed) return
hasAlreadyFailed = true
dispatch('error', { type: 'load', error: err })
} finally {
loading = false
loadingMore = false
}
}
export async function deleteItem(id: string) {
if (!deleteItemFn) return
try {
items = items?.map((i) => (i.id === id ? { ...i, isDeleting: true } : i)) ?? []
setTimeout(async () => {
deleteItemFn ? await deleteItemFn(id) : null
if (selectedItemId === id) {
selectedItemId = null
}
loadData('refresh')
}, 100)
} catch (err) {
dispatch('error', { type: 'delete', error: err })
}
}
export async function setLoader(loader: (page: number, perPage: number) => Promise<any[]>) {
loadInputs = loader
loadData('forceRefresh')
}
export async function setDeleteItemFn(fn: (id: any) => Promise<any>) {
deleteItemFn = fn
}
</script>
<DataTable
size="xs"
infiniteScroll
{hasMore}
tableFixed={true}
on:loadMore={() => {
loadData()
}}
{loading}
{loadingMore}
{rounded}
{noBorder}
{neverShowLoader}
{preventXOverflow}
>
{@render columns?.()}
<tbody class="h-full w-full">
{#if extra_row}
<Row
on:click={() => dispatch('select', 'extraRow')}
class={twMerge(
extraRowClasses.class,
selectedItemId === 'extraRow' ? extraRowClasses.bgSelected : extraRowClasses.bgHover,
'cursor-pointer rounded-md'
)}
on:hover={(e) => (hovered = e.detail ? 'extraRow' : undefined)}
>
{@render extra_row?.({ hover: hovered === 'extraRow' })}
</Row>
{/if}
{#each items ?? [] as item, index}
{@const hover = item.id === hovered}
{#if customRow}
{@render customRow?.({ item, hover })}
{:else}
<Row
on:click={() => dispatch('select', item)}
class={twMerge(
selectedItemId === item.id ? 'bg-surface-selected' : 'hover:bg-surface-hover',
'cursor-pointer rounded-md',
item.isNew && index === 0 ? 'animate-slideIn' : 'group'
)}
on:hover={(e) => (hovered = e.detail ? item.id : undefined)}
>
{@render children?.({ item, hover })}
</Row>
{/if}
{/each}
</tbody>
{#snippet emptyMessage()}
{#if (!items || items?.length === 0) && (!loading || neverShowLoader)}
{@render empty?.({ items })}
{/if}
{/snippet}
</DataTable>
<style>
@keyframes slideOut {
from {
opacity: 1;
transform: translateX(0);
max-height: 100px;
}
to {
opacity: 0;
transform: translateX(-100%);
max-height: 0;
margin: 0;
padding: 0;
}
}
@keyframes greenHighlight {
0% {
background-color: rgba(70, 255, 138, 0.4);
box-shadow: 0 0 15px rgb(34 197 94 / 0.3);
}
100% {
background-color: transparent;
box-shadow: none;
}
}
:global(.animate-slideIn) {
animation: greenHighlight 1s ease-out forwards;
will-change: transform, opacity, background-color, box-shadow;
position: relative;
}
</style>