Refresh processing tracks live, apply publish and delete optimistically and guard unsaved album edits
This commit is contained in:
@@ -11,9 +11,10 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
tracks: LibraryTrack[];
|
tracks: LibraryTrack[];
|
||||||
memberIds: string[];
|
memberIds: string[];
|
||||||
|
onchange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { tracks, memberIds }: Props = $props();
|
let { tracks, memberIds, onchange }: Props = $props();
|
||||||
|
|
||||||
const [send, receive] = transferBetweenLists();
|
const [send, receive] = transferBetweenLists();
|
||||||
|
|
||||||
@@ -28,10 +29,12 @@
|
|||||||
|
|
||||||
function add(track: LibraryTrack) {
|
function add(track: LibraryTrack) {
|
||||||
currentIds = [...currentIds, track.id];
|
currentIds = [...currentIds, track.id];
|
||||||
|
onchange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(track: LibraryTrack) {
|
function remove(track: LibraryTrack) {
|
||||||
currentIds = currentIds.filter((id) => id !== track.id);
|
currentIds = currentIds.filter((id) => id !== track.id);
|
||||||
|
onchange?.();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
import type { Album, LibraryTrack } from '$lib/types';
|
import type { Album, LibraryTrack } from '$lib/types';
|
||||||
import { albumCover } from '$lib/utils/hrefs';
|
import { albumCover } from '$lib/utils/hrefs';
|
||||||
import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte';
|
import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte';
|
||||||
@@ -20,39 +21,52 @@
|
|||||||
const isNew = $derived(album === null);
|
const isNew = $derived(album === null);
|
||||||
|
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
beforeNavigate((navigation) => {
|
||||||
|
if (!dirty || confirmingDelete) return;
|
||||||
|
if (!confirm('Tienes cambios sin guardar. ¿Salir sin guardar?')) navigation.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
function saved() {
|
||||||
|
dirty = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AlbumForm
|
<div oninput={() => (dirty = true)} role="presentation">
|
||||||
action="?/saveAlbum"
|
<AlbumForm
|
||||||
layout="split"
|
action="?/saveAlbum"
|
||||||
{artist}
|
layout="split"
|
||||||
showType
|
{artist}
|
||||||
initialType={album ? (album.type.toLowerCase() as 'album' | 'ep' | 'single') : 'album'}
|
showType
|
||||||
initialTitle={album?.title}
|
initialType={album ? (album.type.toLowerCase() as 'album' | 'ep' | 'single') : 'album'}
|
||||||
initialDescription={album?.description ?? ''}
|
initialTitle={album?.title}
|
||||||
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
|
initialDescription={album?.description ?? ''}
|
||||||
titlePlaceholder="Sin título"
|
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
|
||||||
coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined}
|
titlePlaceholder="Sin título"
|
||||||
coverHint="Las canciones del álbum usan esta portada."
|
coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined}
|
||||||
requireCover={isNew}
|
coverHint="Las canciones del álbum usan esta portada."
|
||||||
{formMessage}
|
requireCover={isNew}
|
||||||
submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'}
|
{formMessage}
|
||||||
submittingLabel={isNew ? 'Creando…' : 'Guardando…'}
|
submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'}
|
||||||
onCancel={onClose}
|
submittingLabel={isNew ? 'Creando…' : 'Guardando…'}
|
||||||
onSuccess={onClose}
|
onCancel={onClose}
|
||||||
>
|
onSuccess={saved}
|
||||||
{#snippet aside()}
|
>
|
||||||
<input type="hidden" name="albumId" value={album?.id ?? ''} />
|
{#snippet aside()}
|
||||||
<AlbumTrackPicker {tracks} {memberIds} />
|
<input type="hidden" name="albumId" value={album?.id ?? ''} />
|
||||||
{/snippet}
|
<AlbumTrackPicker {tracks} {memberIds} onchange={() => (dirty = true)} />
|
||||||
{#snippet footer()}
|
{/snippet}
|
||||||
{#if album}
|
{#snippet footer()}
|
||||||
<Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}>
|
{#if album}
|
||||||
Eliminar álbum
|
<Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}>
|
||||||
</Button>
|
Eliminar álbum
|
||||||
{/if}
|
</Button>
|
||||||
{/snippet}
|
{/if}
|
||||||
</AlbumForm>
|
{/snippet}
|
||||||
|
</AlbumForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if album}
|
{#if album}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
const FADE = { duration: 200 };
|
const FADE = { duration: 200 };
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
const REFRESH_MIN_MS = 3000;
|
||||||
|
const REFRESH_MAX_MS = 30000;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick, untrack } from 'svelte';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { goto } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import SearchX from '@lucide/svelte/icons/search-x';
|
import SearchX from '@lucide/svelte/icons/search-x';
|
||||||
@@ -62,14 +65,103 @@
|
|||||||
let publishValue = $state('true');
|
let publishValue = $state('true');
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
let hiddenIds = $state<string[]>([]);
|
||||||
|
let overrides = $state<Record<string, boolean>>({});
|
||||||
|
let deleteError = $state('');
|
||||||
|
let announcement = $state('');
|
||||||
|
let lastKey = '';
|
||||||
|
let previousProcessing = untrack(() => counts.processing);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
list.reset(tracks);
|
const key = `${query}|${filter}|${tracks.totalItemCount}`;
|
||||||
|
untrack(() => {
|
||||||
|
if (key !== lastKey) {
|
||||||
|
lastKey = key;
|
||||||
|
list.reset(tracks);
|
||||||
|
} else {
|
||||||
|
list.merge(tracks.items);
|
||||||
|
}
|
||||||
|
});
|
||||||
search = query;
|
search = query;
|
||||||
const ids = new Set(list.items.map((track) => track.id));
|
|
||||||
selectedIds = selectedIds.filter((id) => ids.has(id));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = $derived(list.items);
|
const items = $derived(
|
||||||
|
list.items
|
||||||
|
.filter((track) => !hiddenIds.includes(track.id))
|
||||||
|
.map((track) =>
|
||||||
|
track.id in overrides ? { ...track, published: overrides[track.id] } : track
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const ids = new Set(items.map((track) => track.id));
|
||||||
|
if (selectedIds.some((id) => !ids.has(id)))
|
||||||
|
selectedIds = selectedIds.filter((id) => ids.has(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const processing = $derived(
|
||||||
|
counts.processing > 0 ||
|
||||||
|
list.items.some((track) => track.status === 'Pending' || track.status === 'Processing')
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!processing) return;
|
||||||
|
let delay = REFRESH_MIN_MS;
|
||||||
|
let stopped = false;
|
||||||
|
let handle: ReturnType<typeof setTimeout>;
|
||||||
|
const refresh = async () => {
|
||||||
|
if (document.visibilityState === 'visible') await invalidateAll();
|
||||||
|
delay = Math.min(delay * 1.5, REFRESH_MAX_MS);
|
||||||
|
if (!stopped) handle = setTimeout(refresh, delay);
|
||||||
|
};
|
||||||
|
handle = setTimeout(refresh, delay);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(handle);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const now = counts.processing;
|
||||||
|
if (now < previousProcessing) {
|
||||||
|
announcement = `${plural(previousProcessing - now, 'canción terminó', 'canciones terminaron')} de procesarse.`;
|
||||||
|
}
|
||||||
|
previousProcessing = now;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSubmit: SubmitFunction = () => {
|
||||||
|
const ids = pendingDelete.map((track) => track.id);
|
||||||
|
hiddenIds = [...hiddenIds, ...ids];
|
||||||
|
deleteError = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
hiddenIds = hiddenIds.filter((id) => !ids.includes(id));
|
||||||
|
deleteError =
|
||||||
|
(result.data as { message?: string } | undefined)?.message ??
|
||||||
|
'No se pudieron borrar las canciones.';
|
||||||
|
await invalidateAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.removeTracks(ids);
|
||||||
|
selectedIds = [];
|
||||||
|
pendingDelete = [];
|
||||||
|
await update({ reset: false });
|
||||||
|
hiddenIds = hiddenIds.filter((id) => !ids.includes(id));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishSubmit: SubmitFunction = ({ formData }) => {
|
||||||
|
const ids = String(formData.get('trackIds') ?? '').split(',');
|
||||||
|
const value = formData.get('isPublished') === 'true';
|
||||||
|
overrides = { ...overrides, ...Object.fromEntries(ids.map((id) => [id, value])) };
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
overrides = {};
|
||||||
|
selectedIds = [];
|
||||||
|
pendingUnpublish = [];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const hasFilters = $derived(query !== '' || filter !== 'all');
|
const hasFilters = $derived(query !== '' || filter !== 'all');
|
||||||
const counters = $derived(filterCounts(counts));
|
const counters = $derived(filterCounts(counts));
|
||||||
const filterOptions = $derived(
|
const filterOptions = $derived(
|
||||||
@@ -119,6 +211,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<p class="sr-only" aria-live="polite">{announcement}</p>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
|
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
|
||||||
{:else if notice}
|
{:else if notice}
|
||||||
@@ -130,11 +224,7 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/setPublished"
|
action="?/setPublished"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
use:enhance={() =>
|
use:enhance={publishSubmit}
|
||||||
async ({ update }) => {
|
|
||||||
await update({ reset: false });
|
|
||||||
selectedIds = [];
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="trackIds" value={publishIds} />
|
<input type="hidden" name="trackIds" value={publishIds} />
|
||||||
<input type="hidden" name="isPublished" value={publishValue} />
|
<input type="hidden" name="isPublished" value={publishValue} />
|
||||||
@@ -218,11 +308,16 @@
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={pendingDelete.length > 0}
|
open={pendingDelete.length > 0}
|
||||||
onClose={() => (pendingDelete = [])}
|
onClose={() => {
|
||||||
|
pendingDelete = [];
|
||||||
|
deleteError = '';
|
||||||
|
}}
|
||||||
title="¿Borrar {pendingTitle(pendingDelete)}?"
|
title="¿Borrar {pendingTitle(pendingDelete)}?"
|
||||||
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
|
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
|
||||||
confirmLabel="Borrar"
|
confirmLabel="Borrar"
|
||||||
action="?/deleteTracks"
|
action="?/deleteTracks"
|
||||||
|
submit={deleteSubmit}
|
||||||
|
error={deleteError}
|
||||||
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
|
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -233,5 +328,6 @@
|
|||||||
description="Dejarán de verse en álbumes, playlists, búsquedas y perfiles. Solo tú podrás verlas y reproducirlas."
|
description="Dejarán de verse en álbumes, playlists, búsquedas y perfiles. Solo tú podrás verlas y reproducirlas."
|
||||||
confirmLabel="Despublicar"
|
confirmLabel="Despublicar"
|
||||||
action="?/setPublished"
|
action="?/setPublished"
|
||||||
|
submit={publishSubmit}
|
||||||
fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }}
|
fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
@@ -10,19 +13,44 @@
|
|||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
action: string;
|
action: string;
|
||||||
fields?: Record<string, string>;
|
fields?: Record<string, string>;
|
||||||
|
submit?: SubmitFunction;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
|
let {
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
action,
|
||||||
|
fields = {},
|
||||||
|
submit,
|
||||||
|
error
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet body()}
|
||||||
|
{#each Object.entries(fields) as [name, value] (name)}
|
||||||
|
<input type="hidden" {name} {value} />
|
||||||
|
{/each}
|
||||||
|
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<Dialog {open} {onClose} {title} {description}>
|
<Dialog {open} {onClose} {title} {description}>
|
||||||
|
{#if error}
|
||||||
|
<Alert tone="danger" class="mt-4">{error}</Alert>
|
||||||
|
{/if}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
||||||
<form method="POST" {action}>
|
{#if submit}
|
||||||
{#each Object.entries(fields) as [name, value] (name)}
|
<form method="POST" {action} use:enhance={submit}>
|
||||||
<input type="hidden" {name} {value} />
|
{@render body()}
|
||||||
{/each}
|
</form>
|
||||||
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
{:else}
|
||||||
</form>
|
<form method="POST" {action}>
|
||||||
|
{@render body()}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { readStorage, writeStorage } from '$lib/utils/storage';
|
|||||||
import { ListenTracker } from './listenTracker';
|
import { ListenTracker } from './listenTracker';
|
||||||
import { mediaSession } from './mediaSession';
|
import { mediaSession } from './mediaSession';
|
||||||
import { ProgressClock } from './progressClock';
|
import { ProgressClock } from './progressClock';
|
||||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
import {
|
||||||
|
append,
|
||||||
|
insertNext,
|
||||||
|
isLastIndex,
|
||||||
|
move,
|
||||||
|
nextIndex,
|
||||||
|
previousIndex,
|
||||||
|
removeTracks
|
||||||
|
} from './queue';
|
||||||
import { resolveStream } from './stream';
|
import { resolveStream } from './stream';
|
||||||
|
|
||||||
const RECENT_LIMIT = 15;
|
const RECENT_LIMIT = 15;
|
||||||
@@ -281,6 +289,27 @@ class PlayerState {
|
|||||||
this.tracks = move(this.tracks, from, insertAt);
|
this.tracks = move(this.tracks, from, insertAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeTracks(ids: string[]) {
|
||||||
|
const gone = new Set(ids);
|
||||||
|
if (!this.tracks.some((track) => gone.has(track.id))) return;
|
||||||
|
const result = removeTracks(this.tracks, gone, this.#index());
|
||||||
|
this.tracks = result.queue;
|
||||||
|
if (!result.currentRemoved) return;
|
||||||
|
const next = result.queue[result.nextIndex] ?? result.queue[0];
|
||||||
|
if (next) {
|
||||||
|
this.currentId = next.id;
|
||||||
|
this.#loadCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#audio?.pause();
|
||||||
|
this.#loadToken++;
|
||||||
|
this.currentId = null;
|
||||||
|
this.playing = false;
|
||||||
|
this.loading = false;
|
||||||
|
this.progress = 0;
|
||||||
|
mediaSession.setPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
clearUpcoming() {
|
clearUpcoming() {
|
||||||
this.tracks = this.current ? [this.current] : [];
|
this.tracks = this.current ? [this.current] : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
import {
|
||||||
|
append,
|
||||||
|
insertNext,
|
||||||
|
isLastIndex,
|
||||||
|
move,
|
||||||
|
nextIndex,
|
||||||
|
previousIndex,
|
||||||
|
removeTracks
|
||||||
|
} from './queue';
|
||||||
|
|
||||||
const t = (id: string) => ({ id });
|
const t = (id: string) => ({ id });
|
||||||
const ids = (queue: { id: string }[]) => queue.map((item) => item.id);
|
const ids = (queue: { id: string }[]) => queue.map((item) => item.id);
|
||||||
@@ -83,3 +91,22 @@ describe('isLastIndex', () => {
|
|||||||
expect(isLastIndex(3, 1)).toBe(false);
|
expect(isLastIndex(3, 1)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('removeTracks', () => {
|
||||||
|
it('keeps the queue and current track when others are removed', () => {
|
||||||
|
const result = removeTracks(['a', 'b', 'c'].map(t), new Set(['a']), 1);
|
||||||
|
expect(ids(result.queue)).toEqual(['b', 'c']);
|
||||||
|
expect(result.currentRemoved).toBe(false);
|
||||||
|
});
|
||||||
|
it('points at the track that followed the removed current one', () => {
|
||||||
|
const result = removeTracks(['a', 'b', 'c', 'd'].map(t), new Set(['a', 'c']), 2);
|
||||||
|
expect(ids(result.queue)).toEqual(['b', 'd']);
|
||||||
|
expect(result.currentRemoved).toBe(true);
|
||||||
|
expect(result.nextIndex).toBe(1);
|
||||||
|
});
|
||||||
|
it('reports an empty queue when everything is removed', () => {
|
||||||
|
const result = removeTracks(['a'].map(t), new Set(['a']), 0);
|
||||||
|
expect(result.queue).toEqual([]);
|
||||||
|
expect(result.currentRemoved).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ export function append<T extends Identified>(queue: T[], items: T[]): T[] {
|
|||||||
return added.length > 0 ? [...queue, ...added] : queue;
|
return added.length > 0 ? [...queue, ...added] : queue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeTracks<T extends Identified>(
|
||||||
|
queue: T[],
|
||||||
|
ids: ReadonlySet<string>,
|
||||||
|
currentIndex: number
|
||||||
|
): { queue: T[]; currentRemoved: boolean; nextIndex: number } {
|
||||||
|
const remaining = queue.filter((item) => !ids.has(item.id));
|
||||||
|
const currentRemoved = currentIndex >= 0 && ids.has(queue[currentIndex]?.id ?? '');
|
||||||
|
const before =
|
||||||
|
currentIndex < 0 ? 0 : queue.slice(0, currentIndex).filter((i) => !ids.has(i.id)).length;
|
||||||
|
return { queue: remaining, currentRemoved, nextIndex: before };
|
||||||
|
}
|
||||||
|
|
||||||
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
|
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
|
||||||
if (from < 0 || from >= queue.length) return queue;
|
if (from < 0 || from >= queue.length) return queue;
|
||||||
const target = insertAt > from ? insertAt - 1 : insertAt;
|
const target = insertAt > from ? insertAt - 1 : insertAt;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export function createPagedList<T>(
|
|||||||
loading = false;
|
loading = false;
|
||||||
error = false;
|
error = false;
|
||||||
},
|
},
|
||||||
|
merge(fresh: T[]) {
|
||||||
|
const byKey = new Map(fresh.map((item) => [keyOf(item), item]));
|
||||||
|
items = items.map((item) => byKey.get(keyOf(item)) ?? item);
|
||||||
|
},
|
||||||
async loadMore() {
|
async loadMore() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user