mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-04-14 03:50:20 +00:00
feat: torrent status
This commit is contained in:
parent
0c2614e6c4
commit
eb542299fe
18 changed files with 300 additions and 17 deletions
16
src/app.d.ts
vendored
16
src/app.d.ts
vendored
|
|
@ -38,6 +38,20 @@ export interface Attachment {
|
|||
url: string
|
||||
}
|
||||
|
||||
export interface TorrentInfo {
|
||||
peers: number
|
||||
progress: number
|
||||
down: number
|
||||
up: number
|
||||
name: string
|
||||
hash: string
|
||||
seeders: number
|
||||
leechers: number
|
||||
size: number
|
||||
downloaded: number
|
||||
eta: number
|
||||
}
|
||||
|
||||
export interface Native {
|
||||
authAL: (url: string) => Promise<AuthResponse>
|
||||
restart: () => Promise<void>
|
||||
|
|
@ -67,6 +81,8 @@ export interface Native {
|
|||
tracks: (hash: string, id: number) => Promise<Array<{ number: string, language?: string, type: string, header: string, name?: string }>>
|
||||
subtitles: (hash: string, id: number, cb: (subtitle: { text: string, time: number, duration: number }, trackNumber: number) => void) => Promise<void>
|
||||
chapters: (hash: string, id: number) => Promise<Array<{ start: number, end: number, text: string }>>
|
||||
torrentStats: (hash: string) => Promise<TorrentInfo>
|
||||
torrents: () => Promise<TorrentInfo[]>
|
||||
isApp: boolean
|
||||
version: () => string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang='ts'>
|
||||
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward, Captions, Contrast, List, PictureInPicture2, Proportions, RefreshCcw, RotateCcw, RotateCw, ScreenShare, Volume1, Volume2, VolumeX } from 'lucide-svelte'
|
||||
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward, Captions, Contrast, List, PictureInPicture2, Proportions, RefreshCcw, RotateCcw, RotateCw, ScreenShare, Volume1, Volume2, VolumeX, ChevronDown, ChevronUp, Users } from 'lucide-svelte'
|
||||
import { persisted } from 'svelte-persisted-store'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { onMount } from 'svelte'
|
||||
import { loadWithDefaults } from 'svelte-keybinds'
|
||||
import { condition, loadWithDefaults } from 'svelte-keybinds'
|
||||
import VideoDeband from 'video-deband'
|
||||
|
||||
import Seekbar from './seekbar.svelte'
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
import type { TorrentFile } from '../../../../app'
|
||||
import type { ResolvedFile } from './resolver'
|
||||
|
||||
import { server } from '$lib/modules/torrent'
|
||||
import PictureInPictureExit from '$lib/components/icons/PictureInPictureExit.svelte'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import PictureInPicture from '$lib/components/icons/PictureInPicture.svelte'
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
import Play from '$lib/components/icons/Play.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { settings } from '$lib/modules/settings'
|
||||
import { toTS } from '$lib/utils'
|
||||
import { toTS, fastPrettyBits } from '$lib/utils'
|
||||
import native from '$lib/modules/native'
|
||||
import { click } from '$lib/modules/navigate'
|
||||
import { goto } from '$app/navigation'
|
||||
|
|
@ -543,7 +544,12 @@
|
|||
}
|
||||
})
|
||||
|
||||
const torrentstats = server.stats
|
||||
|
||||
$: isMiniplayer = $page.route.id !== '/app/player'
|
||||
|
||||
// @ts-expect-error bad type infer
|
||||
$condition = () => !isMiniplayer
|
||||
</script>
|
||||
|
||||
<svelte:document bind:fullscreenElement bind:visibilityState />
|
||||
|
|
@ -574,6 +580,21 @@
|
|||
on:timeupdate={checkCompletion}
|
||||
/>
|
||||
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
|
||||
<div class='absolute top-0 flex w-full pointer-events-none justify-center z-50 gap-4 pt-3 items-center font-bold text-lg' class:hidden={isMiniplayer}>
|
||||
<!-- {($torrentstats.progress * 100).toFixed(1)}% -->
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<Users size={18} />
|
||||
{$torrentstats.seeders}
|
||||
</div>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<ChevronDown size={18} />
|
||||
{fastPrettyBits($torrentstats.down)}/s
|
||||
</div>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<ChevronUp size={18} />
|
||||
{fastPrettyBits($torrentstats.up)}/s
|
||||
</div>
|
||||
</div>
|
||||
{#if seeking}
|
||||
{#await thumbnailer.getThumbnail(seekIndex) then src}
|
||||
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0 object-contain' loading='lazy' decoding='async' />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang='ts'>
|
||||
import { Home, Search, Calendar, Users, MessagesSquare, Heart, Settings, LogIn } from 'lucide-svelte'
|
||||
import { Download } from 'svelte-radix'
|
||||
|
||||
import { BannerImage } from '../banner'
|
||||
import { Button } from '../button'
|
||||
|
||||
import SidebarButton from './SidebarButton.svelte'
|
||||
|
||||
import Hub from '$lib/components/icons/Hub.svelte'
|
||||
import native from '$lib/modules/native'
|
||||
import client from '$lib/modules/auth/client'
|
||||
import * as Avatar from '$lib/components/ui/avatar'
|
||||
|
|
@ -36,14 +36,15 @@
|
|||
<SidebarButton href='/app/schedule/'>
|
||||
<Calendar size={18} />
|
||||
</SidebarButton>
|
||||
<SidebarButton href='/app/w2g/'>
|
||||
<!-- <SidebarButton href='/app/w2g/'> -->
|
||||
<SidebarButton disabled={true}>
|
||||
<Users size={18} />
|
||||
</SidebarButton>
|
||||
<SidebarButton href='/app/chat/'>
|
||||
<MessagesSquare size={18} />
|
||||
</SidebarButton>
|
||||
<SidebarButton href='/app/client/'>
|
||||
<Hub size={18} fill='currentColor' />
|
||||
<Download size={18} />
|
||||
</SidebarButton>
|
||||
<Button variant='ghost' on:click={() => native.openURL('https://github.com/sponsors/ThaUnknown/')} class='px-2 w-full relative mt-auto select:!bg-transparent text-[#fa68b6] select:text-[#fa68b6]'>
|
||||
<Heart size={18} fill='currentColor' class='absolute' />
|
||||
|
|
|
|||
28
src/lib/components/ui/table/index.ts
Normal file
28
src/lib/components/ui/table/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Root from './table.svelte'
|
||||
import Body from './table-body.svelte'
|
||||
import Caption from './table-caption.svelte'
|
||||
import Cell from './table-cell.svelte'
|
||||
import Footer from './table-footer.svelte'
|
||||
import Head from './table-head.svelte'
|
||||
import Header from './table-header.svelte'
|
||||
import Row from './table-row.svelte'
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow
|
||||
}
|
||||
14
src/lib/components/ui/table/table-body.svelte
Normal file
14
src/lib/components/ui/table/table-body.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLTableSectionElement>
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<tbody class={cn('[&_tr:last-child]:border-0', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</tbody>
|
||||
14
src/lib/components/ui/table/table-caption.svelte
Normal file
14
src/lib/components/ui/table/table-caption.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLTableCaptionElement>
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<caption class={cn('text-muted-foreground mt-4 text-sm', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</caption>
|
||||
22
src/lib/components/ui/table/table-cell.svelte
Normal file
22
src/lib/components/ui/table/table-cell.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLTdAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLTdAttributes
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<td
|
||||
class={cn(
|
||||
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
14
src/lib/components/ui/table/table-footer.svelte
Normal file
14
src/lib/components/ui/table/table-footer.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLTableSectionElement>
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<tfoot class={cn('bg-muted/50 text-primary-foreground font-medium', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</tfoot>
|
||||
20
src/lib/components/ui/table/table-head.svelte
Normal file
20
src/lib/components/ui/table/table-head.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLThAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLThAttributes
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<th
|
||||
class={cn(
|
||||
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
15
src/lib/components/ui/table/table-header.svelte
Normal file
15
src/lib/components/ui/table/table-header.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLTableSectionElement>
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<thead class={cn('[&_tr]:border-b', className)} {...$$restProps} on:click on:keydown>
|
||||
<slot />
|
||||
</thead>
|
||||
24
src/lib/components/ui/table/table-row.svelte
Normal file
24
src/lib/components/ui/table/table-row.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLTableRowElement> & {
|
||||
'data-state'?: unknown
|
||||
}
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
16
src/lib/components/ui/table/table.svelte
Normal file
16
src/lib/components/ui/table/table.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script lang='ts'>
|
||||
import type { HTMLTableAttributes } from 'svelte/elements'
|
||||
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
type $$Props = HTMLTableAttributes
|
||||
|
||||
let className: $$Props['class'] = undefined
|
||||
export { className as class }
|
||||
</script>
|
||||
|
||||
<div class='relative w-full overflow-auto'>
|
||||
<table class={cn('w-full caption-bottom text-sm', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import type { AuthResponse, Native } from '../../app'
|
||||
import type { AuthResponse, Native, TorrentInfo } from '../../app'
|
||||
|
||||
const rnd = (range = 100) => Math.floor(Math.random() * range)
|
||||
|
||||
const dummyFiles = [
|
||||
{
|
||||
name: 'My Happy Marriage Season 2.webm',
|
||||
name: 'Amebku.webm',
|
||||
hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
type: 'video/webm',
|
||||
size: 1234567890,
|
||||
|
|
@ -68,7 +70,8 @@ export default Object.assign<Native, Partial<Native>>({
|
|||
{ start: 1.0 * 60, end: 1.2 * 60, text: 'Chapter 1' },
|
||||
{ start: 1.4 * 60, end: 88, text: 'Chapter 2 ' }
|
||||
],
|
||||
version: () => 'v0.0.2'
|
||||
|
||||
version: () => 'v0.0.2',
|
||||
torrentStats: async (): Promise<TorrentInfo> => ({ peers: rnd(), seeders: rnd(), leechers: rnd(), progress: Math.random(), down: rnd(100000000), up: rnd(100000000), name: 'Amebku.webm', downloaded: rnd(100000), hash: '1234567890abcdef', size: 1234567890, eta: rnd() }),
|
||||
torrents: async (): Promise<TorrentInfo[]> => [{ peers: rnd(), seeders: rnd(), leechers: rnd(), progress: Math.random(), down: rnd(100000000), up: rnd(100000000), name: 'Amebku.webm', downloaded: rnd(100000), hash: '1234567890abcdef', size: 1234567890, eta: rnd() }]
|
||||
// @ts-expect-error idk
|
||||
}, globalThis.native as Partial<Native>)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,41 @@
|
|||
import { writable } from 'simple-store-svelte'
|
||||
import { readable, writable } from 'simple-store-svelte'
|
||||
import { persisted } from 'svelte-persisted-store'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
import native from '../native'
|
||||
|
||||
import type { Media } from '../anilist'
|
||||
import type { TorrentFile } from '../../../app'
|
||||
import type { TorrentFile, TorrentInfo } from '../../../app'
|
||||
|
||||
export const server = new class ServerClient {
|
||||
last = persisted<{media: Media, id: string, episode: number} | null>('last-torrent', null)
|
||||
active = writable<Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>>()
|
||||
|
||||
stats = readable<TorrentInfo>({ peers: 0, down: 0, up: 0, progress: 0, downloaded: 0, eta: 0, hash: '', leechers: 0, name: '', seeders: 0, size: 0 }, set => {
|
||||
let listener = 0
|
||||
|
||||
const update = async () => {
|
||||
const id = (await get(this.active))?.id
|
||||
if (id) set(await native.torrentStats(id))
|
||||
listener = setTimeout(update, 1000)
|
||||
}
|
||||
|
||||
update()
|
||||
return () => clearTimeout(listener)
|
||||
})
|
||||
|
||||
list = readable<TorrentInfo[]>([], set => {
|
||||
let listener = 0
|
||||
|
||||
const update = async () => {
|
||||
set(await native.torrents())
|
||||
listener = setTimeout(update, 1000)
|
||||
}
|
||||
|
||||
update()
|
||||
return () => clearTimeout(listener)
|
||||
})
|
||||
|
||||
constructor () {
|
||||
const last = get(this.last)
|
||||
if (last) this.play(last.id, last.media, last.episode)
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const isMobile = readable(!mql?.matches, set => {
|
|||
})
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat('en')
|
||||
const formatterShort = new Intl.RelativeTimeFormat('en', { style: 'short' })
|
||||
const ranges: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
|
||||
years: 3600 * 24 * 365,
|
||||
months: 3600 * 24 * 30,
|
||||
|
|
@ -115,12 +116,22 @@ export function since (date: Date) {
|
|||
}
|
||||
}
|
||||
}
|
||||
export function eta (date: Date) {
|
||||
const secondsElapsed = (date.getTime() - Date.now()) / 1000
|
||||
for (const _key in ranges) {
|
||||
const key = _key as Intl.RelativeTimeFormatUnit
|
||||
if ((ranges[key] ?? 0) < Math.abs(secondsElapsed)) {
|
||||
const delta = secondsElapsed / (ranges[key] ?? 0)
|
||||
return formatterShort.format(Math.round(delta), key)
|
||||
}
|
||||
}
|
||||
}
|
||||
const bytes = [' B', ' kB', ' MB', ' GB', ' TB']
|
||||
export function fastPrettyBytes (num: number) {
|
||||
if (isNaN(num)) return '0 B'
|
||||
if (num < 1) return num + ' B'
|
||||
const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), bytes.length - 1)
|
||||
return Number((num / Math.pow(1000, exponent)).toFixed(2)) + bytes[exponent]!
|
||||
return Number((num / Math.pow(1000, exponent)).toFixed(1)) + bytes[exponent]!
|
||||
}
|
||||
|
||||
const bits = [' b', ' kb', ' Mb', ' Gb', ' Tb']
|
||||
|
|
@ -128,7 +139,7 @@ export function fastPrettyBits (num: number) {
|
|||
if (isNaN(num)) return '0 b'
|
||||
if (num < 1) return num + ' b'
|
||||
const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), bits.length - 1)
|
||||
return Number((num / Math.pow(1000, exponent)).toFixed(2)) + bits[exponent]!
|
||||
return Number((num / Math.pow(1000, exponent)).toFixed(1)) + bits[exponent]!
|
||||
}
|
||||
|
||||
export function toTS (sec: number, full?: number) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,9 @@
|
|||
<BannerImage class='absolute top-0 left-0' />
|
||||
<SearchModal />
|
||||
<div class='flex flex-row grow h-full overflow-hidden relative'>
|
||||
|
||||
<Sidebar>
|
||||
<Sidebarlist />
|
||||
</Sidebar>
|
||||
<Player />
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@
|
|||
</div>
|
||||
<div class='flex gap-2 items-center justify-center md:justify-start w-full lex-wrap'>
|
||||
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
|
||||
<PlayButton size='default' {media} class='rounded-r-none' />
|
||||
<PlayButton size='default' {media} class='rounded-r-none w-full' />
|
||||
<EntryEditor {media} />
|
||||
</div>
|
||||
<FavoriteButton {media} variant='secondary' size='icon' class='min-[380px]:-order-1 md:order-none' />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang='ts'>
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { dragScroll } from '$lib/modules/navigate'
|
||||
import { server } from '$lib/modules/torrent'
|
||||
import { fastPrettyBits, fastPrettyBytes, eta as _eta } from '$lib/utils'
|
||||
|
||||
const list = server.list
|
||||
</script>
|
||||
|
||||
<div class='flex flex-col items-center w-full h-full overflow-y-auto px-5 my-10' use:dragScroll>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row class='[&>*]:p-4 [&>*]:font-bold'>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class='w-[100px]'>Progress</Table.Head>
|
||||
<Table.Head class='w-[100px]'>Size</Table.Head>
|
||||
<Table.Head class='w-[100px]'>Done</Table.Head>
|
||||
<Table.Head class='w-[110px]'>Download</Table.Head>
|
||||
<Table.Head class='w-[110px]'>Upload</Table.Head>
|
||||
<Table.Head class='w-[110px]'>ETA</Table.Head>
|
||||
<Table.Head class='w-[100px]'>Seeders</Table.Head>
|
||||
<Table.Head class='w-[100px]'>Leechers</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each $list as { name, progress, size, down, up, eta, seeders, leechers, peers }, i (i)}
|
||||
<Table.Row class='[&>*]:p-4'>
|
||||
<Table.Cell>{name}</Table.Cell>
|
||||
<Table.Cell>{(progress * 100).toFixed(1)}%</Table.Cell>
|
||||
<Table.Cell>{fastPrettyBytes(size)}</Table.Cell>
|
||||
<Table.Cell>{fastPrettyBytes(size * progress)}</Table.Cell>
|
||||
<Table.Cell>{fastPrettyBits(down)}/s</Table.Cell>
|
||||
<Table.Cell>{fastPrettyBits(up)}/s</Table.Cell>
|
||||
<Table.Cell>{_eta(new Date(Date.now() + eta * 1000))}</Table.Cell>
|
||||
<Table.Cell>{seeders}<span class='text-muted-foreground'>/{peers}</span></Table.Cell>
|
||||
<Table.Cell>{leechers}<span class='text-muted-foreground'>/{peers}</span></Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
Loading…
Reference in a new issue