feat: library and files filtering

feat: library rescan and delete buttons [disabled for now]
This commit is contained in:
ThaUnknown 2025-09-01 22:57:08 +02:00
parent 7e8ac47cdf
commit 9e1cdb7017
No known key found for this signature in database
21 changed files with 1232 additions and 511 deletions

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "6.4.111",
"version": "6.4.112",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.15.5",
@ -20,8 +20,8 @@
},
"devDependencies": {
"@gql.tada/svelte-support": "^1.0.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/debug": "^4.1.12",
"@types/semver": "^7.7.0",
@ -29,8 +29,8 @@
"autoprefixer": "^10.4.21",
"bits-ui": "^0.22.0",
"cmdk-sv": "^0.0.19",
"eslint-config-standard-universal": "^1.0.8",
"gql.tada": "^1.8.10",
"eslint-config-standard-universal": "^1.0.9",
"gql.tada": "^1.8.13",
"hayase-extensions": "github:hayase-app/extensions",
"jassub": "^1.8.6",
"ms": "^2.1.3",
@ -38,44 +38,45 @@
"rollup-plugin-license": "^3.6.0",
"simple-copy": "^2.2.1",
"svelte": "^4.2.19",
"svelte-check": "^4.2.1",
"svelte-check": "^4.3.1",
"svelte-radix": "^1.1.1",
"svelte-sonner": "^0.3.28",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"vaul-svelte": "^0.3.2",
"vite": "^5.4.11",
"vite-plugin-static-copy": "^3.0.2"
"vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-static-copy": "^3.1.2"
},
"type": "module",
"dependencies": {
"@cloudflare/speedtest": "^1.4.1",
"@fontsource-variable/nunito": "^5.2.5",
"@cloudflare/speedtest": "^1.6.0",
"@fontsource-variable/nunito": "^5.2.6",
"@fontsource/geist-mono": "^5.2.6",
"@prgm/sveltekit-progress-bar": "2.0.0",
"@thaunknown/web-irc": "^1.0.3",
"@urql/core": "^5.2.0",
"@urql/exchange-auth": "^2.2.1",
"@urql/exchange-graphcache": "^7.2.3",
"@urql/exchange-refocus": "^1.1.1",
"@urql/exchange-request-policy": "^1.2.1",
"@urql/exchange-retry": "^1.3.1",
"@urql/svelte": "^4.2.3",
"abslink": "^1.1.0",
"@urql/core": "^6.0.1",
"@urql/exchange-auth": "^3.0.0",
"@urql/exchange-graphcache": "^8.1.0",
"@urql/exchange-refocus": "^2.0.0",
"@urql/exchange-request-policy": "^2.0.0",
"@urql/exchange-retry": "^2.0.0",
"@urql/svelte": "^5.0.0",
"abslink": "^1.1.2",
"anitomyscript": "github:thaunknown/anitomyscript",
"bittorrent-tracker": "10.0.12",
"bittorrent-tracker": "11.2.1",
"bottleneck": "^2.19.5",
"clsx": "^2.1.1",
"cobe": "0.6.3",
"date-fns": "^4.1.0",
"debug": "^4.4.1",
"doc999tor-fast-geoip": "^1.1.335",
"dompurify": "^3.2.5",
"doc999tor-fast-geoip": "^1.1.360",
"dompurify": "^3.2.6",
"events": "^3.3.0",
"idb-keyval": "^6.2.2",
"js-levenshtein": "^1.1.6",
"lucide-svelte": "^0.511.0",
"marked": "^15.0.11",
"lucide-svelte": "^0.542.0",
"marked": "^16.2.1",
"overtype": "^1.2.3",
"p2pt": "github:ThaUnknown/p2pt#modernise",
"semver": "^7.7.2",
@ -83,10 +84,10 @@
"svelte-headless-table": "^0.18.3",
"svelte-keybinds": "^1.0.9",
"svelte-persisted-store": "^0.12.0",
"tailwind-merge": "^3.3.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"uint8-util": "^2.2.5",
"urql": "^4.2.2",
"urql": "^5.0.1",
"video-deband": "^1.0.9",
"wonka": "^6.3.5",
"workbox-core": "^7.3.0",

File diff suppressed because it is too large Load diff

View file

@ -167,7 +167,7 @@ a {
-webkit-user-drag: none;
}
:fullscreen {
#episodeListTarget:fullscreen {
user-select: none;
}

View file

@ -19,7 +19,7 @@
const key = 'active-settings-tab'
</script>
<nav class={cn('md:flex grid grid-cols-2 md:flex-row lg:flex-col gap-y-1 gap-x-2', className)}>
<nav class={cn('md:flex grid grid-cols-2 md:flex-row lg:flex-col gap-y-1 gap-x-2 pb-2 sm:pb-0', className)}>
{#each items as { href, title }, i (i)}
{@const isActive = $page.url.pathname === href}
<Button {href} variant='ghost' data-sveltekit-noscroll class='relative font-semibold justify-start last:odd:col-span-2'>

View file

@ -0,0 +1,43 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path
d='M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5'
/>
<g class='target-animated-icon'>
<path d='M12 10v4h4' />
<path d='m12 14 1.535-1.605a5 5 0 0 1 8 1.5' />
<path d='M22 22v-4h-4' />
<path d='m22 18-1.535 1.605a5 5 0 0 1-8-1.5' />
</g>
</svg>
<style>
.target-animated-icon {
transform-origin: center;
transform-box: fill-box;
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: rotate(-50deg);
}
</style>

View file

@ -16,3 +16,4 @@ export { default as Trash } from './trash.svelte'
export { default as FileImage } from './fileimage.svelte'
export { default as Minimize } from './minimize.svelte'
export { default as Maximize } from './maximize.svelte'
export { default as FolderSync } from './foldersync.svelte'

View file

@ -29,7 +29,7 @@
<slot />
</Button>
</Dialog.Trigger>
<Dialog.Content tabindex={null} class='gap-4 bottom-0 border-b-0 !translate-y-[unset] p-0 top-[unset] !pb-4 flex flex-col h-full sm:h-1/2'>
<Dialog.Content tabindex={null} class='gap-4 bottom-0 border-b-0 !translate-y-[unset] p-0 top-[unset] !pb-4 flex flex-col h-[90%] sm:h-1/2'>
<Markdown class='form-control w-full shrink-0 min-h-56 rounded-none flex-grow' {placeholder} bind:value />
<div class='flex gap-2 justify-end flex-grow-0 px-4'>
<Dialog.Close asChild let:builder>

View file

@ -1,7 +1,9 @@
<script lang='ts'>
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy } from 'svelte-headless-table/plugins'
import { addSortBy, addTableFilter } from 'svelte-headless-table/plugins'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { Input } from '../../input'
import Columnheader from '../columnheader.svelte'
import { NameCell, ProgressCell } from './cells'
@ -11,7 +13,10 @@
import { cn, fastPrettyBytes } from '$lib/utils'
const table = createTable(server.files, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
sort: addSortBy({ toggleOrder: ['asc', 'desc'] }),
filter: addTableFilter({
fn: ({ filterValue, value }) => value.toLowerCase().includes(filterValue.toLowerCase())
})
})
const columns = table.createColumns([
@ -25,12 +30,14 @@
accessor: 'size',
header: 'Size',
id: 'size',
plugins: { filter: { exclude: true } },
cell: ({ value }) => fastPrettyBytes(value)
}),
table.column({
accessor: 'progress',
header: 'Progress',
id: 'progress',
plugins: { filter: { exclude: true } },
cell: ({ value }) => createRender(ProgressCell, { value })
}),
table.column({ accessor: 'selections', header: 'Streams', id: 'selections' })
@ -38,9 +45,18 @@
const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
const { headerRows, pageRows, tableAttrs, tableBodyAttrs, pluginStates } = tableModel
const filterValue = pluginStates.filter.filterValue
</script>
<div class='flex items-center scale-parent relative pb-2 overflow-visible'>
<Input
class='pl-9 bg-black select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
placeholder='Search by File Name...'
bind:value={$filterValue} />
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
</div>
<div class='rounded-md border size-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'>
@ -74,7 +90,7 @@
{#if $pageRows.length}
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class='h-12'>
<Table.Row {...rowAttrs} class='h-12 [content-visibility:auto] [contain-intrinsic-height:auto_48px] contain-strict'>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', cell.id === 'name' && 'text-wrap break-all')}>

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import type { HTMLButtonAttributes } from 'svelte/elements'
import type { Writable } from 'svelte/store'
import { Checkbox } from '$lib/components/ui/checkbox'
type $$Props = HTMLButtonAttributes & {
checked: Writable<boolean>
}
export let checked: Writable<boolean>
</script>
<div class='contents' on:click|stopPropagation|stopImmediatePropagation>
<Checkbox bind:checked={$checked} {...$$restProps} class='mx-4' />
</div>

View file

@ -2,3 +2,4 @@ export { default as StatusCell } from './status.svelte'
export { default as NameCell } from './name.svelte'
export { default as MediaCell } from './mediatitle.svelte'
export { default as DateCell } from './date.svelte'
export { default as CheckboxCell } from './checkboxcell.svelte'

View file

@ -1,23 +1,33 @@
<script lang='ts'>
import { DataBodyRow, Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy } from 'svelte-headless-table/plugins'
import { addSelectedRows, addSortBy, addTableFilter } from 'svelte-headless-table/plugins'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { toast } from 'svelte-sonner'
import { Button } from '../../button'
import Columnheader from '../columnheader.svelte'
import { MediaCell, NameCell, StatusCell, DateCell } from './cells'
import { MediaCell, NameCell, StatusCell, DateCell, CheckboxCell } from './cells'
import type { LibraryEntry } from 'native'
import { goto } from '$app/navigation'
import { FolderSync, Trash } from '$lib/components/icons/animated'
import { Input } from '$lib/components/ui/input'
import * as Table from '$lib/components/ui/table'
import { client } from '$lib/modules/anilist'
import native from '$lib/modules/native'
import { server } from '$lib/modules/torrent'
import { cn, fastPrettyBytes } from '$lib/utils'
const lib = server.library
const table = createTable(lib, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
select: addSelectedRows(),
sort: addSortBy({ toggleOrder: ['asc', 'desc'] }),
filter: addTableFilter({
fn: ({ filterValue, value }) => value.toLowerCase().includes(filterValue.toLowerCase())
})
})
const columns = table.createColumns([
@ -25,36 +35,36 @@
accessor: 'mediaID',
header: 'Series',
id: 'series',
plugins: { sort: { getSortValue: e => e ?? 0 } },
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? createRender(MediaCell, { value }) : '?'
}),
table.column({
accessor: 'episode',
header: 'Episode',
id: 'episode',
plugins: { sort: { getSortValue: e => e ?? 0 } },
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value?.toString() ?? '?'
}),
table.column({ accessor: 'files', header: 'Files', id: 'files' }),
table.column({ accessor: 'files', header: 'Files', id: 'files', plugins: { filter: { exclude: true } } }),
table.column({
accessor: 'size',
header: 'Size',
id: 'size',
plugins: { sort: { getSortValue: e => e ?? 0 } },
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? fastPrettyBytes(value) : '?'
}),
table.column({
accessor: 'progress',
header: 'Status',
id: 'completed',
plugins: { sort: { getSortValue: e => e ?? 0 } },
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? createRender(StatusCell, { value: value === 1 }) : '?'
}),
table.column({
accessor: 'date',
header: 'Date',
id: 'date',
plugins: { sort: { getSortValue: e => e ?? 0 } },
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? createRender(DateCell, { value }) : '?'
}),
table.column({
@ -63,12 +73,35 @@
id: 'name',
plugins: { sort: { getSortValue: e => e ?? '' } },
cell: ({ value }) => createRender(NameCell, { value })
}),
table.display({
id: 'select',
header: (_, { pluginStates }) => {
const { allPageRowsSelected } = pluginStates.select
return createRender(CheckboxCell, {
checked: allPageRowsSelected,
'aria-label': 'Select all'
})
},
cell: ({ row }, { pluginStates }) => {
const { getRowState } = pluginStates.select
const { isSelected } = getRowState(row)
return createRender(CheckboxCell, {
checked: isSelected,
'aria-label': 'Select row'
})
},
plugins: {
sort: {
disable: true
}
}
})
])
const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
const { headerRows, pageRows, tableAttrs, tableBodyAttrs, pluginStates } = tableModel
async function playEntry ({ mediaID, episode, hash }: LibraryEntry) {
if (!mediaID || !hash) return
@ -78,10 +111,60 @@
goto('/app/player/')
}
const { filterValue } = pluginStates.filter
const { selectedDataIds } = pluginStates.select
function getSelected () {
return Object.keys($selectedDataIds).map(id => $lib[id as unknown as number]?.hash).filter(e => e) as string[]
}
// TODO: enable
function rescanTorrents () {
return null
// eslint-disable-next-line no-unreachable
toast.promise(native.rescanTorrents(getSelected()), {
loading: 'Rescanning torrents...',
success: 'Rescan complete',
error: e => {
console.error(e)
return 'Failed to rescan torrents\n' + ('stack' in (e as object) ? (e as Error).stack : 'Unknown error')
},
description: 'This may take a long while depending on the number of torrents.'
})
}
function deleteTorrents () {
return null
// eslint-disable-next-line no-unreachable
toast.promise(native.deleteTorrents(getSelected()), {
loading: 'Deleting torrents...',
success: 'Torrents deleted',
error: e => {
console.error(e)
return 'Failed to delete torrents\n' + ('stack' in (e as object) ? (e as Error).stack : 'Unknown error')
},
description: 'This may take a while depending on the number of torrents.'
})
}
// TODO once new resolver is implemented
// $: allIDsPromise = client.multiple($lib.map(e => e.mediaID))
</script>
<div class='flex gap-2'>
<div class='flex items-center scale-parent relative pb-2 overflow-visible grow'>
<Input
class='pl-9 bg-black select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
placeholder='Search by Torrent Name...'
bind:value={$filterValue} />
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
</div>
<Button variant='secondary' size='icon' class='border-0 animated-icon !pointer-events-auto cursor-not-allowed' disabled on:click={rescanTorrents}>
<FolderSync class={cn('size-4')} />
</Button>
<Button variant='destructive' size='icon' class='border-0 animated-icon !pointer-events-auto cursor-not-allowed' disabled on:click={deleteTorrents}>
<Trash class={cn('size-4')} />
</Button>
</div>
<div class='rounded-md border size-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'>
@ -109,10 +192,15 @@
{#if $pageRows.length}
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class={cn('h-14', (row instanceof DataBodyRow) && row.original.mediaID ? 'cursor-pointer' : 'cursor-not-allowed')} on:click={() => { if (row instanceof DataBodyRow) playEntry(row.original) }}>
<Table.Row {...rowAttrs} class={cn('h-14 [content-visibility:auto] [contain-intrinsic-height:auto_56px] contain-strict', (row instanceof DataBodyRow) && row.original.mediaID ? 'cursor-pointer' : 'cursor-not-allowed')} on:click={() => { if (row instanceof DataBodyRow) playEntry(row.original) }}>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 min-h-14 first:pl-6 last:pr-6 text-nowrap', (cell.id === 'episode') && 'text-muted-foreground', (cell.id === 'series' || cell.id === 'name') && 'min-w-80 text-wrap break-all')}>
<Table.Cell {...attrs} class={cn(
'px-4 min-h-14 first:pl-6 last:pr-6 text-nowrap',
(cell.id === 'episode') && 'text-muted-foreground',
(cell.id === 'series' || cell.id === 'name') && 'min-w-80 text-wrap break-all',
cell.id === 'select' && 'p-0'
)}>
<Render of={cell.render()} />
</Table.Cell>
</Subscribe>

View file

@ -110,6 +110,7 @@ export default new class URQLClient extends Client {
constructor () {
super({
url: 'https://graphql.anilist.co',
preferGetMethod: false,
// fetch: dev ? fetch : (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
fetch: (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
exchanges: [

View file

@ -102,6 +102,8 @@ const ENDPOINTS = {
API_ANIME: 'https://api.myanimelist.net/v2/anime'
} as const
const clientID = 'd93b624a92e431a9b6dfe7a66c0c5bbb'
export default new class MALSync {
auth = persisted<MALOAuth | undefined>('malAuth', undefined)
viewer = persisted<ResultOf<typeof UserFrag> | undefined>('malViewer', undefined)
@ -169,7 +171,7 @@ export default new class MALSync {
if (auth) {
const expiresAt = (auth.created_at + auth.expires_in) * 1000
if (expiresAt < Date.now() - 1000 * 60 * 5) { // 5 minutes before expiry
if (expiresAt < Date.now() - 1000 * 60 * 5 && !body?.get('refresh_token')) { // 5 minutes before expiry
await this._refresh()
}
}
@ -248,6 +250,7 @@ export default new class MALSync {
const data = await this._post<MALOAuth>(
ENDPOINTS.API_OAUTH,
{
client_id: clientID,
grant_type: 'refresh_token',
refresh_token: auth.refresh_token
}
@ -265,7 +268,6 @@ export default new class MALSync {
debug('Logging in to MAL')
const state = crypto.randomUUID().replaceAll('-', '')
const challenge = (crypto.randomUUID() + crypto.randomUUID()).replaceAll('-', '')
const clientID = 'd93b624a92e431a9b6dfe7a66c0c5bbb'
const redirect = dev ? 'http://localhost:7344/authorize' : 'https://hayase.app/authorize'

View file

@ -36,7 +36,7 @@ export async function encryptMessage (message: string) {
iv: key
},
await derived,
text2arr(message)
text2arr(message).buffer as ArrayBuffer
))))
}
@ -47,6 +47,6 @@ export async function decryptMessage (encryptedMessage: string) {
iv: key
},
await derived,
hex2arr(bin2hex(encryptedMessage))
hex2arr(bin2hex(encryptedMessage)).buffer as ArrayBuffer
))
}

View file

@ -116,6 +116,8 @@ export default Object.assign<Native, Partial<Native>>({
updatePeerCounts: async () => [],
isApp: false,
playTorrent: async () => dummyFiles,
rescanTorrents: async () => undefined,
deleteTorrents: async () => undefined,
library: async () => [],
attachments: async () => [],
tracks: async () => [],

View file

@ -59,10 +59,10 @@
<aside class='lg:grow lg:max-w-60 flex flex-col'>
<SettingsNav {items} />
<div class='mt-auto text-xs text-muted-foreground px-4 sm:px-2 py-3 md:py-5 flex-row lg:flex-col font-light gap-0.5 gap-x-4 flex-wrap hidden sm:flex'>
<div>WebTorrent v2.6.8</div>
<div>WebTorrent v2.8.4</div>
</div>
</aside>
<div class='flex-1 overflow-y-scroll' use:dragScroll>
<div class='flex-1' use:dragScroll>
{#if !SUPPORTS.isUnderPowered}
<Globe />
{/if}

View file

@ -2,6 +2,6 @@
import { Overview } from '$lib/components/ui/torrentclient'
</script>
<div class='flex flex-col h-full'>
<div class='flex flex-col h-full overflow-y-scroll'>
<Overview />
</div>

View file

@ -2,4 +2,6 @@
import { FilesTable } from '$lib/components/ui/torrentclient'
</script>
<FilesTable />
<div class='flex flex-col size-full'>
<FilesTable />
</div>

View file

@ -2,4 +2,6 @@
import { LibraryTable } from '$lib/components/ui/torrentclient'
</script>
<LibraryTable />
<div class='flex flex-col size-full'>
<LibraryTable />
</div>

View file

@ -18,8 +18,6 @@
import native from '$lib/modules/native'
import { w2globby } from '$lib/modules/w2g/lobby'
export let data
$: users = $w2globby!.peers
$: messages = $w2globby!.messages

View file

@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { sveltekit } from '@sveltejs/kit/vite'
import license from 'rollup-plugin-license'
import { defineConfig } from 'vite'
import devtoolsJson from 'vite-plugin-devtools-json'
import { viteStaticCopy } from 'vite-plugin-static-copy'
export default defineConfig({
@ -22,7 +23,8 @@ export default defineConfig({
dest: 'geoip/'
}
]
})
}),
devtoolsJson()
],
resolve: {
alias: {