feat: 修改交互逻辑,支持前端查看日志 (#378)
This commit is contained in:
@@ -223,14 +223,22 @@ class ApiClient {
|
||||
return this.get<DashBoardResponse>('/dashboard');
|
||||
}
|
||||
|
||||
// 获取系统信息流(SSE)
|
||||
async getSysInfoStream(): Promise<EventSource> {
|
||||
createLogStream(
|
||||
onMessage: (data: string) => void,
|
||||
onError?: (error: Event) => void
|
||||
): EventSource {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
return new EventSource(url);
|
||||
const url = `/api/logs${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const eventSource = new EventSource(url);
|
||||
eventSource.onmessage = (event) => {
|
||||
onMessage(event.data);
|
||||
};
|
||||
if (onError) {
|
||||
eventSource.onerror = onError;
|
||||
}
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
// 创建系统信息流的便捷方法
|
||||
createSysInfoStream(
|
||||
onMessage: (data: SysInfoResponse) => void,
|
||||
onError?: (error: Event) => void
|
||||
@@ -238,7 +246,6 @@ class ApiClient {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SysInfoResponse;
|
||||
@@ -247,11 +254,9 @@ class ApiClient {
|
||||
console.error('Failed to parse SSE data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (onError) {
|
||||
eventSource.onerror = onError;
|
||||
}
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
}
|
||||
@@ -282,11 +287,12 @@ const api = {
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||
getDashboard: () => apiClient.getDashboard(),
|
||||
getSysInfoStream: () => apiClient.getSysInfoStream(),
|
||||
createSysInfoStream: (
|
||||
onMessage: (data: SysInfoResponse) => void,
|
||||
onError?: (error: Event) => void
|
||||
) => apiClient.createSysInfoStream(onMessage, onError),
|
||||
createLogStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) =>
|
||||
apiClient.createLogStream(onMessage, onError),
|
||||
setAuthToken: (token: string) => apiClient.setAuthToken(token),
|
||||
clearAuthToken: () => apiClient.clearAuthToken()
|
||||
};
|
||||
|
||||
@@ -1,226 +1,144 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database';
|
||||
import FileVideoIcon from '@lucide/svelte/icons/file-video';
|
||||
import BotIcon from '@lucide/svelte/icons/bot';
|
||||
import ChartPieIcon from '@lucide/svelte/icons/chart-pie';
|
||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import SquareTerminalIcon from '@lucide/svelte/icons/square-terminal';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||
import {
|
||||
appStateStore,
|
||||
setVideoSourceFilter,
|
||||
clearAll,
|
||||
ToQuery,
|
||||
resetCurrentPage
|
||||
} from '$lib/stores/filter';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
import { type VideoSourcesResponse } from '$lib/types';
|
||||
import { VIDEO_SOURCES } from '$lib/consts';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { videoSourceStore } from '$lib/stores/video-source';
|
||||
const sidebar = useSidebar();
|
||||
|
||||
const items = Object.values(VIDEO_SOURCES);
|
||||
|
||||
function handleSourceClick(sourceType: string, sourceId: number) {
|
||||
setVideoSourceFilter({
|
||||
type: sourceType,
|
||||
id: sourceId.toString()
|
||||
});
|
||||
resetCurrentPage();
|
||||
goto(`/${ToQuery($appStateStore)}`);
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogoClick() {
|
||||
clearAll();
|
||||
goto('/');
|
||||
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}
|
||||
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
|
||||
const data = {
|
||||
header: {
|
||||
title: 'Bili Sync',
|
||||
subtitle: '后台管理系统',
|
||||
icon: BotIcon,
|
||||
href: '/'
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
category: '总览',
|
||||
items: [
|
||||
{
|
||||
title: '仪表盘',
|
||||
icon: ChartPieIcon,
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
title: '日志',
|
||||
icon: SquareTerminalIcon,
|
||||
href: '/logs'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '内容管理',
|
||||
items: [
|
||||
{
|
||||
title: '视频',
|
||||
icon: FileVideoIcon,
|
||||
href: '/videos'
|
||||
},
|
||||
{
|
||||
title: '视频源',
|
||||
icon: DatabaseIcon,
|
||||
href: '/video-sources'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '快捷订阅',
|
||||
items: [
|
||||
{
|
||||
title: '收藏夹',
|
||||
icon: HeartIcon,
|
||||
href: '/me/favorites'
|
||||
},
|
||||
{
|
||||
title: '合集',
|
||||
icon: FolderIcon,
|
||||
href: '/me/collections'
|
||||
},
|
||||
{
|
||||
title: 'up 主',
|
||||
icon: UserIcon,
|
||||
href: '/me/uppers'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
footer: [
|
||||
{
|
||||
title: '设置',
|
||||
icon: Settings2Icon,
|
||||
href: '/settings'
|
||||
}
|
||||
]
|
||||
};
|
||||
</script>
|
||||
|
||||
<Sidebar.Root class="border-border bg-background border-r">
|
||||
<Sidebar.Header class="border-border flex h-[73px] items-center border-b">
|
||||
<a
|
||||
href="/"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 hover:cursor-pointer"
|
||||
onclick={handleLogoClick}
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg">
|
||||
<img src="/favicon.png" alt="Bili Sync" class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">Bili Sync</span>
|
||||
<span class="text-muted-foreground truncate text-xs">视频管理系统</span>
|
||||
</div>
|
||||
</a>
|
||||
<Sidebar.Root bind:ref variant="inset" {...restProps}>
|
||||
<Sidebar.Header>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton size="lg">
|
||||
{#snippet child({ props })}
|
||||
<a href={data.header.href} {...props}>
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<data.header.icon class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{data.header.title}</span>
|
||||
<span class="truncate text-xs">{data.header.subtitle}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content class="flex flex-col px-2 py-3">
|
||||
<div class="flex-1">
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel
|
||||
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
|
||||
>
|
||||
视频筛选
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu class="space-y-1">
|
||||
{#each items as item (item.type)}
|
||||
<Collapsible.Root class="group/collapsible">
|
||||
<Sidebar.MenuItem>
|
||||
<Collapsible.Trigger class="w-full">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<item.icon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">{item.title}</span>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
class="text-muted-foreground h-3 w-3 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-1">
|
||||
<div class="border-border ml-5 space-y-0.5 border-l pl-2">
|
||||
{#if $videoSourceStore}
|
||||
{#if $videoSourceStore[item.type as keyof VideoSourcesResponse]?.length > 0}
|
||||
{#each $videoSourceStore[item.type as keyof VideoSourcesResponse] as source (source.id)}
|
||||
<Sidebar.MenuItem>
|
||||
<button
|
||||
class="text-foreground hover:bg-accent/50 w-full cursor-pointer rounded-md px-3 py-2 text-left text-sm transition-all duration-200"
|
||||
onclick={() => handleSourceClick(item.type, source.id)}
|
||||
>
|
||||
<span class="block truncate">{source.name}</span>
|
||||
</button>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-muted-foreground px-3 py-2 text-sm">无数据</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-muted-foreground px-3 py-2 text-sm">加载中...</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel
|
||||
class="text-muted-foreground mb-2 px-2 text-xs font-medium tracking-wider uppercase"
|
||||
>
|
||||
快捷订阅
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu class="space-y-1">
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
{#each data.navMain as group (group.category)}
|
||||
<Sidebar.GroupLabel class="h-10">{group.category}</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
{#each group.items as item (item.title)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
<a
|
||||
href="/me/favorites"
|
||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
|
||||
onclick={() => {
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HeartIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span>创建的收藏夹</span>
|
||||
</a>
|
||||
<Sidebar.MenuButton class="h-8">
|
||||
{#snippet child({ props })}
|
||||
<a href={item.href} {...props}>
|
||||
<item.icon class="size-4" />
|
||||
<span class="text-sm">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
<a
|
||||
href="/me/collections"
|
||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
|
||||
onclick={() => {
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span>关注的合集</span>
|
||||
</a>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
<a
|
||||
href="/me/uppers"
|
||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200"
|
||||
onclick={() => {
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span>关注的 UP 主</span>
|
||||
</a>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
</div>
|
||||
|
||||
<!-- 固定在底部的菜单选项 -->
|
||||
<div class="border-border mt-auto border-t pt-4">
|
||||
<Sidebar.Menu class="space-y-1">
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
<a
|
||||
href="/video-sources"
|
||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
|
||||
onclick={() => {
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">视频源管理</span>
|
||||
</div>
|
||||
</a>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
<a
|
||||
href="/settings"
|
||||
class="hover:bg-accent/50 text-foreground flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 font-medium transition-all duration-200"
|
||||
onclick={() => {
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.setOpenMobile(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<SettingsIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">设置</span>
|
||||
</div>
|
||||
</a>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</div>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
{/each}
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.Separator />
|
||||
<Sidebar.Menu>
|
||||
{#each data.footer as item (item.title)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton class="h-8">
|
||||
{#snippet child({ props })}
|
||||
<a href={item.href} {...props}>
|
||||
<item.icon class="size-4" />
|
||||
<span class="text-sm">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
|
||||
@@ -4,30 +4,21 @@
|
||||
export let items: Array<{
|
||||
href?: string;
|
||||
label: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = [{ href: '/', label: '主页' }];
|
||||
</script>
|
||||
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
{#each items as item, index (item.label)}
|
||||
<Breadcrumb.Item>
|
||||
{#if item.isActive || (!item.href && !item.onClick)}
|
||||
<Breadcrumb.Page>{item.label}</Breadcrumb.Page>
|
||||
{:else if item.onClick}
|
||||
<button
|
||||
class="hover:text-foreground cursor-pointer transition-colors"
|
||||
onclick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{:else}
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
{#if item.href}
|
||||
<Breadcrumb.Link href={item.href}>{item.label}</Breadcrumb.Link>
|
||||
{:else}
|
||||
<Breadcrumb.Page>{item.label}</Breadcrumb.Page>
|
||||
{/if}
|
||||
</Breadcrumb.Item>
|
||||
{#if index < items.length - 1}
|
||||
<Breadcrumb.Separator />
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
{/if}
|
||||
{/each}
|
||||
</Breadcrumb.List>
|
||||
|
||||
122
web/src/lib/components/dropdown-filter.svelte
Normal file
122
web/src/lib/components/dropdown-filter.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { tick } from 'svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Command from '$lib/components/ui/command/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
|
||||
export interface Filter {
|
||||
name: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
icon: any;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SelectedLabel {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters: Record<string, Filter> | null;
|
||||
selectedLabel: SelectedLabel | null;
|
||||
onSelect?: (type: string, id: string) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let { filters, selectedLabel = $bindable(), onSelect, onRemove }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerRef = $state<HTMLButtonElement>(null!);
|
||||
|
||||
// We want to refocus the trigger button when the user selects
|
||||
// an item from the list so users can continue navigating the
|
||||
// rest of the form with the keyboard.
|
||||
function closeAndFocusTrigger() {
|
||||
open = false;
|
||||
tick().then(() => {
|
||||
triggerRef.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center gap-1">
|
||||
{#if filters}
|
||||
<span class="bg-secondary text-secondary-foreground rounded-lg px-2 py-1 text-xs font-medium">
|
||||
{#if selectedLabel && selectedLabel.type && selectedLabel.id}
|
||||
「{filters[selectedLabel.type]?.name || ''} : {filters[selectedLabel.type]!.values[
|
||||
selectedLabel.id
|
||||
] || ''}」
|
||||
{:else}
|
||||
未应用
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger bind:ref={triggerRef}>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="ghost" size="sm" {...props} class="h-6 w-6 p-0">
|
||||
<EllipsisIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="w-[200px]" align="end">
|
||||
<DropdownMenu.Group>
|
||||
{#if filters}
|
||||
{#each Object.entries(filters) as [key, filter] (key)}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>
|
||||
<filter.icon class="mr-2 size-3" />
|
||||
<span class="text-xs font-medium">
|
||||
{filter.name}
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent class="p-0">
|
||||
<Command.Root
|
||||
value={selectedLabel && selectedLabel.type === key ? selectedLabel.id : ''}
|
||||
>
|
||||
<Command.Input
|
||||
class="text-xs"
|
||||
autofocus
|
||||
placeholder="查找{filter.name.toLowerCase()}..."
|
||||
/>
|
||||
<Command.List>
|
||||
<Command.Empty class="text-xs"
|
||||
>未找到"{filter.name.toLowerCase()}"</Command.Empty
|
||||
>
|
||||
<Command.Group>
|
||||
{#each Object.entries(filter.values) as [id, name] (id)}
|
||||
<Command.Item
|
||||
value={id}
|
||||
class="text-xs"
|
||||
onSelect={() => {
|
||||
closeAndFocusTrigger();
|
||||
onSelect?.(key, id);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/each}
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
closeAndFocusTrigger();
|
||||
onRemove?.();
|
||||
}}
|
||||
>
|
||||
<TrashIcon class="mr-2 size-3" />
|
||||
<span class="text-xs font-medium"> 移除筛选 </span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
export let filterTitle: string = '';
|
||||
export let filterName: string = '';
|
||||
export let onRemove: () => void = () => {};
|
||||
</script>
|
||||
|
||||
{#if filterTitle && filterName}
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">当前筛选:</span>
|
||||
<Badge variant="secondary" class="flex items-center gap-2 pr-1">
|
||||
<span>{filterTitle}: {filterName}</span>
|
||||
<button
|
||||
class="hover:bg-muted-foreground/20 ml-1 cursor-pointer rounded-full p-0.5 transition-colors"
|
||||
onclick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,31 +1,28 @@
|
||||
<script lang="ts">
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import * as Input from '$lib/components/ui/input/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
|
||||
export let placeholder: string = '搜索视频..';
|
||||
export let value: string = '';
|
||||
export let onSearch: ((query: string) => void) | undefined = undefined;
|
||||
|
||||
function handleSearch() {
|
||||
if (onSearch) {
|
||||
onSearch(value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
onSearch?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center space-x-2">
|
||||
<div class="relative flex-1">
|
||||
<SearchIcon class="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input.Root type="text" {placeholder} bind:value onkeydown={handleKeydown} class="h-11 pl-10" />
|
||||
<div class="flex w-full max-w-48 items-center">
|
||||
<div class="relative w-full">
|
||||
<SearchIcon class="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input.Root
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:value
|
||||
class="h-8 w-full border-0 pr-3 pl-7 text-sm shadow-none focus-visible:ring-0"
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onclick={handleSearch} size="default" class="h-11 flex-shrink-0 cursor-pointer px-8"
|
||||
>搜索</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -39,21 +39,16 @@
|
||||
pageStatuses = { ...pageStatuses };
|
||||
}
|
||||
|
||||
// 编辑状态
|
||||
let videoStatuses: number[] = [];
|
||||
let pageStatuses: Record<number, number[]> = {};
|
||||
|
||||
// 原始状态备份
|
||||
let originalVideoStatuses: number[] = [];
|
||||
let originalPageStatuses: Record<number, number[]> = {};
|
||||
|
||||
// 响应式更新状态 - 当 video 或 pages props 变化时重新初始化
|
||||
$: {
|
||||
// 初始化视频状态
|
||||
videoStatuses = [...video.download_status];
|
||||
originalVideoStatuses = [...video.download_status];
|
||||
|
||||
// 初始化分页状态
|
||||
if (pages.length > 0) {
|
||||
pageStatuses = pages.reduce(
|
||||
(acc, page) => {
|
||||
@@ -77,20 +72,28 @@
|
||||
|
||||
function handleVideoStatusChange(taskIndex: number, newValue: number) {
|
||||
videoStatuses[taskIndex] = newValue;
|
||||
videoStatuses = [...videoStatuses];
|
||||
}
|
||||
|
||||
function handlePageStatusChange(pageId: number, taskIndex: number, newValue: number) {
|
||||
if (!pageStatuses[pageId]) {
|
||||
pageStatuses[pageId] = [];
|
||||
return;
|
||||
}
|
||||
pageStatuses[pageId][taskIndex] = newValue;
|
||||
pageStatuses = { ...pageStatuses };
|
||||
}
|
||||
|
||||
function resetAllStatuses() {
|
||||
videoStatuses = [...originalVideoStatuses];
|
||||
pageStatuses = { ...originalPageStatuses };
|
||||
if (pages.length > 0) {
|
||||
pageStatuses = pages.reduce(
|
||||
(acc, page) => {
|
||||
acc[page.id] = [...page.download_status];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, number[]>
|
||||
);
|
||||
} else {
|
||||
pageStatuses = {};
|
||||
}
|
||||
}
|
||||
|
||||
function hasVideoChanges(): boolean {
|
||||
@@ -112,44 +115,37 @@
|
||||
function buildRequest(): UpdateVideoStatusRequest {
|
||||
const request: UpdateVideoStatusRequest = {};
|
||||
|
||||
// 构建视频状态更新
|
||||
if (hasVideoChanges()) {
|
||||
request.video_updates = [];
|
||||
videoStatuses.forEach((status, index) => {
|
||||
if (status !== originalVideoStatuses[index]) {
|
||||
request.video_updates!.push({
|
||||
request.video_updates = [];
|
||||
videoStatuses.forEach((status, index) => {
|
||||
if (status !== originalVideoStatuses[index]) {
|
||||
request.video_updates!.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
});
|
||||
request.page_updates = [];
|
||||
pages.forEach((page) => {
|
||||
const currentStatuses = pageStatuses[page.id] || [];
|
||||
const originalStatuses = originalPageStatuses[page.id] || [];
|
||||
const updates: StatusUpdate[] = [];
|
||||
|
||||
currentStatuses.forEach((status, index) => {
|
||||
if (status !== originalStatuses[index]) {
|
||||
updates.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 构建分页状态更新
|
||||
if (hasPageChanges()) {
|
||||
request.page_updates = [];
|
||||
pages.forEach((page) => {
|
||||
const currentStatuses = pageStatuses[page.id] || [];
|
||||
const originalStatuses = originalPageStatuses[page.id] || [];
|
||||
const updates: StatusUpdate[] = [];
|
||||
|
||||
currentStatuses.forEach((status, index) => {
|
||||
if (status !== originalStatuses[index]) {
|
||||
updates.push({
|
||||
status_index: index,
|
||||
status_value: status
|
||||
});
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
request.page_updates!.push({
|
||||
page_id: page.id,
|
||||
updates
|
||||
});
|
||||
|
||||
if (updates.length > 0) {
|
||||
request.page_updates!.push({
|
||||
page_id: page.id,
|
||||
updates
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return request;
|
||||
}
|
||||
@@ -159,8 +155,11 @@
|
||||
toast.info('没有状态变更需要提交');
|
||||
return;
|
||||
}
|
||||
|
||||
const request = buildRequest();
|
||||
if (!request.video_updates?.length && !request.page_updates?.length) {
|
||||
toast.info('没有状态变更需要提交');
|
||||
return;
|
||||
}
|
||||
onsubmit(request);
|
||||
}
|
||||
</script>
|
||||
@@ -179,7 +178,6 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
<div class="space-y-6 py-2">
|
||||
<!-- 视频状态编辑 -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-base font-medium">视频状态</h3>
|
||||
<div class="bg-card rounded-lg border p-4">
|
||||
|
||||
@@ -67,11 +67,11 @@
|
||||
function getSubtitle(): string {
|
||||
switch (type) {
|
||||
case 'favorite':
|
||||
return `UP主ID: ${(item as FavoriteWithSubscriptionStatus).mid}`;
|
||||
return `uid: ${(item as FavoriteWithSubscriptionStatus).mid}`;
|
||||
case 'collection':
|
||||
return `UP主ID: ${(item as CollectionWithSubscriptionStatus).mid}`;
|
||||
return `uid: ${(item as CollectionWithSubscriptionStatus).mid}`;
|
||||
case 'upper':
|
||||
return ''; // UP主不需要副标题
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -158,96 +158,105 @@
|
||||
const disabledReason = getDisabledReason();
|
||||
</script>
|
||||
|
||||
<Card class="group transition-shadow hover:shadow-md {disabled ? 'opacity-60 grayscale' : ''}">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3">
|
||||
<!-- 头像或图标 -->
|
||||
<div
|
||||
class="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-lg {disabled
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if avatarUrl && type === 'upper'}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={title}
|
||||
class="h-full w-full rounded-lg object-cover {disabled ? 'grayscale' : ''}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Icon class="text-muted-foreground h-6 w-6" />
|
||||
{/if}
|
||||
</div>
|
||||
<Card
|
||||
class="hover:shadow-primary/5 border-border/50 group flex h-full flex-col transition-all hover:shadow-lg {disabled
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader class="flex-shrink-0 pb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- 头像或图标 - 简化设计 -->
|
||||
<div
|
||||
class="bg-accent/50 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {disabled
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if avatarUrl && type === 'upper'}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={title}
|
||||
class="h-full w-full rounded-full object-cover {disabled ? 'grayscale' : ''}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Icon class="text-muted-foreground h-5 w-5" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 标题和信息 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- 内容区域 -->
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle
|
||||
class="line-clamp-2 text-base leading-tight {disabled
|
||||
class="line-clamp-2 text-sm leading-relaxed font-medium {disabled
|
||||
? 'text-muted-foreground line-through'
|
||||
: ''}"
|
||||
{title}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{#if subtitle}
|
||||
<div class="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
|
||||
<UserIcon class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate" title={subtitle}>{subtitle}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="text-muted-foreground mt-1 line-clamp-2 text-xs" title={description}>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<!-- 状态标记 -->
|
||||
{#if disabled}
|
||||
<Badge variant="destructive" class="shrink-0 text-xs">不可用</Badge>
|
||||
{:else}
|
||||
<Badge variant={subscribed ? 'outline' : 'secondary'} class="shrink-0 text-xs">
|
||||
{subscribed ? '已订阅' : typeLabel}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态标记 -->
|
||||
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||
{#if disabled}
|
||||
<Badge variant="destructive" class="text-xs">不可用</Badge>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{disabledReason}
|
||||
<!-- 副标题和描述 -->
|
||||
{#if subtitle && !disabled}
|
||||
<div class="text-muted-foreground flex items-center gap-1 text-sm">
|
||||
<UserIcon class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate" title={subtitle}>{subtitle}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if description && !disabled}
|
||||
<p class="text-muted-foreground line-clamp-1 text-sm" title={description}>
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- 计数信息 -->
|
||||
{#if count !== null && !disabled}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
{count}
|
||||
{countLabel}
|
||||
</div>
|
||||
{:else}
|
||||
<Badge variant={subscribed ? 'default' : 'outline'} class="text-xs">
|
||||
{subscribed ? '已订阅' : typeLabel}
|
||||
</Badge>
|
||||
{#if count !== null}
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{count}
|
||||
{countLabel}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="pt-0">
|
||||
<!-- 底部按钮区域 -->
|
||||
<CardContent class="flex min-w-0 flex-1 flex-col justify-end pt-0 pb-4">
|
||||
<div class="flex justify-end">
|
||||
{#if disabled}
|
||||
<Button size="sm" variant="outline" disabled class="cursor-not-allowed opacity-50">
|
||||
<XIcon class="mr-2 h-4 w-4" />
|
||||
不可用
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
class="h-8 cursor-not-allowed text-xs opacity-50"
|
||||
>
|
||||
<XIcon class="mr-1 h-3 w-3" />
|
||||
{disabledReason}
|
||||
</Button>
|
||||
{:else if subscribed}
|
||||
<Button size="sm" variant="outline" disabled class="cursor-not-allowed">
|
||||
<CheckIcon class="mr-2 h-4 w-4" />
|
||||
<Button size="sm" variant="outline" disabled class="h-8 cursor-not-allowed text-xs">
|
||||
<CheckIcon class="mr-1 h-3 w-3" />
|
||||
已订阅
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
variant="outline"
|
||||
onclick={handleSubscribe}
|
||||
class="cursor-pointer"
|
||||
{disabled}
|
||||
class="h-8 cursor-pointer text-xs font-medium"
|
||||
>
|
||||
<PlusIcon class="mr-2 h-4 w-4" />
|
||||
快捷订阅
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
订阅
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -256,3 +265,5 @@
|
||||
|
||||
<!-- 订阅对话框 -->
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||
<!-- 订阅对话框 -->
|
||||
<SubscriptionDialog bind:open={dialogOpen} {item} {type} onSuccess={handleSubscriptionSuccess} />
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
}: CollapsiblePrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Root bind:ref data-slot="collapsible" {...restProps} />
|
||||
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
const Root = CollapsiblePrimitive.Root;
|
||||
const Trigger = CollapsiblePrimitive.Trigger;
|
||||
const Content = CollapsiblePrimitive.Content;
|
||||
import Root from './collapsible.svelte';
|
||||
import Trigger from './collapsible-trigger.svelte';
|
||||
import Content from './collapsible-content.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
|
||||
40
web/src/lib/components/ui/command/command-dialog.svelte
Normal file
40
web/src/lib/components/ui/command/command-dialog.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import Command from './command.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import type { WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(''),
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run',
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
|
||||
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
title?: string;
|
||||
description?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...restProps}>
|
||||
<Dialog.Header class="sr-only">
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>{description}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
|
||||
<Command
|
||||
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]]:px-2 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
|
||||
{...restProps}
|
||||
bind:value
|
||||
bind:ref
|
||||
{children}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
17
web/src/lib/components/ui/command/command-empty.svelte
Normal file
17
web/src/lib/components/ui/command/command-empty.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.EmptyProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty
|
||||
bind:ref
|
||||
data-slot="command-empty"
|
||||
class={cn('py-6 text-center text-sm', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
30
web/src/lib/components/ui/command/command-group.svelte
Normal file
30
web/src/lib/components/ui/command/command-group.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive, useId } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
heading,
|
||||
value,
|
||||
...restProps
|
||||
}: CommandPrimitive.GroupProps & {
|
||||
heading?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Group
|
||||
bind:ref
|
||||
data-slot="command-group"
|
||||
class={cn('text-foreground overflow-hidden p-1', className)}
|
||||
value={value ?? heading ?? `----${useId()}`}
|
||||
{...restProps}
|
||||
>
|
||||
{#if heading}
|
||||
<CommandPrimitive.GroupHeading class="text-muted-foreground px-2 py-1.5 text-xs font-medium">
|
||||
{heading}
|
||||
</CommandPrimitive.GroupHeading>
|
||||
{/if}
|
||||
<CommandPrimitive.GroupItems {children} />
|
||||
</CommandPrimitive.Group>
|
||||
26
web/src/lib/components/ui/command/command-input.svelte
Normal file
26
web/src/lib/components/ui/command/command-input.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(''),
|
||||
...restProps
|
||||
}: CommandPrimitive.InputProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-9 items-center gap-2 border-b px-3" data-slot="command-input-wrapper">
|
||||
<SearchIcon class="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
class={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
bind:value
|
||||
/>
|
||||
</div>
|
||||
20
web/src/lib/components/ui/command/command-item.svelte
Normal file
20
web/src/lib/components/ui/command/command-item.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="command-item"
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
web/src/lib/components/ui/command/command-link-item.svelte
Normal file
20
web/src/lib/components/ui/command/command-link-item.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.LinkItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.LinkItem
|
||||
bind:ref
|
||||
data-slot="command-item"
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
web/src/lib/components/ui/command/command-list.svelte
Normal file
17
web/src/lib/components/ui/command/command-list.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
bind:ref
|
||||
data-slot="command-list"
|
||||
class={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
web/src/lib/components/ui/command/command-separator.svelte
Normal file
17
web/src/lib/components/ui/command/command-separator.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="command-separator"
|
||||
class={cn('bg-border -mx-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
web/src/lib/components/ui/command/command-shortcut.svelte
Normal file
20
web/src/lib/components/ui/command/command-shortcut.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="command-shortcut"
|
||||
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
22
web/src/lib/components/ui/command/command.svelte
Normal file
22
web/src/lib/components/ui/command/command.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(''),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Root
|
||||
bind:value
|
||||
bind:ref
|
||||
data-slot="command"
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
40
web/src/lib/components/ui/command/index.ts
Normal file
40
web/src/lib/components/ui/command/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Command as CommandPrimitive } from 'bits-ui';
|
||||
|
||||
import Root from './command.svelte';
|
||||
import Dialog from './command-dialog.svelte';
|
||||
import Empty from './command-empty.svelte';
|
||||
import Group from './command-group.svelte';
|
||||
import Item from './command-item.svelte';
|
||||
import Input from './command-input.svelte';
|
||||
import List from './command-list.svelte';
|
||||
import Separator from './command-separator.svelte';
|
||||
import Shortcut from './command-shortcut.svelte';
|
||||
import LinkItem from './command-link-item.svelte';
|
||||
|
||||
const Loading = CommandPrimitive.Loading;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
LinkItem,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
LinkItem as CommandLinkItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if indeterminate}
|
||||
<MinusIcon class="size-4" />
|
||||
{:else}
|
||||
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if checked}
|
||||
<CircleIcon class="size-2 fill-current" />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-separator"
|
||||
class={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubTriggerProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||
49
web/src/lib/components/ui/dropdown-menu/index.ts
Normal file
49
web/src/lib/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
|
||||
import Content from './dropdown-menu-content.svelte';
|
||||
import Group from './dropdown-menu-group.svelte';
|
||||
import Item from './dropdown-menu-item.svelte';
|
||||
import Label from './dropdown-menu-label.svelte';
|
||||
import RadioGroup from './dropdown-menu-radio-group.svelte';
|
||||
import RadioItem from './dropdown-menu-radio-item.svelte';
|
||||
import Separator from './dropdown-menu-separator.svelte';
|
||||
import Shortcut from './dropdown-menu-shortcut.svelte';
|
||||
import Trigger from './dropdown-menu-trigger.svelte';
|
||||
import SubContent from './dropdown-menu-sub-content.svelte';
|
||||
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
|
||||
import GroupHeading from './dropdown-menu-group-heading.svelte';
|
||||
const Sub = DropdownMenuPrimitive.Sub;
|
||||
const Root = DropdownMenuPrimitive.Root;
|
||||
|
||||
export {
|
||||
CheckboxItem,
|
||||
Content,
|
||||
Root as DropdownMenu,
|
||||
CheckboxItem as DropdownMenuCheckboxItem,
|
||||
Content as DropdownMenuContent,
|
||||
Group as DropdownMenuGroup,
|
||||
Item as DropdownMenuItem,
|
||||
Label as DropdownMenuLabel,
|
||||
RadioGroup as DropdownMenuRadioGroup,
|
||||
RadioItem as DropdownMenuRadioItem,
|
||||
Separator as DropdownMenuSeparator,
|
||||
Shortcut as DropdownMenuShortcut,
|
||||
Sub as DropdownMenuSub,
|
||||
SubContent as DropdownMenuSubContent,
|
||||
SubTrigger as DropdownMenuSubTrigger,
|
||||
Trigger as DropdownMenuTrigger,
|
||||
GroupHeading as DropdownMenuGroupHeading,
|
||||
Group,
|
||||
GroupHeading,
|
||||
Item,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Root,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Sub,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
Trigger
|
||||
};
|
||||
@@ -24,7 +24,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="separator-root"
|
||||
data-slot="separator"
|
||||
class={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.OverlayProps = $props();
|
||||
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Overlay
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
onclick={sidebar.toggle}
|
||||
title="Toggle Sidebar"
|
||||
class={cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
bind:ref
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
class={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||
class={cn('bg-sidebar-border', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
|
||||
)}
|
||||
></div>
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
class={cn(
|
||||
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
|
||||
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
|
||||
'data-[side=bottom]:translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
|
||||
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
|
||||
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
|
||||
'data-[side=left]:translate-y-[calc(50%_-_3px)]',
|
||||
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
|
||||
arrowClasses
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
export let customSubtitle: string = ''; // 自定义副标题
|
||||
export let taskNames: string[] = []; // 自定义任务名称
|
||||
export let showProgress: boolean = true; // 是否显示进度信息
|
||||
export let progressHeight: string = 'h-2'; // 进度条高度
|
||||
export let gap: string = 'gap-1'; // 进度条间距
|
||||
export let onReset: (() => Promise<void>) | null = null; // 自定义重置函数
|
||||
export let resetDialogOpen = false; // 导出对话框状态,让父组件可以控制
|
||||
export let resetting = false;
|
||||
@@ -35,11 +33,11 @@
|
||||
|
||||
function getSegmentColor(status: number): string {
|
||||
if (status === 7) {
|
||||
return 'bg-green-500'; // 绿色 - 成功
|
||||
return 'bg-emerald-500'; // 恢复更高对比度的绿色
|
||||
} else if (status === 0) {
|
||||
return 'bg-yellow-500'; // 黄色 - 未开始
|
||||
return 'bg-slate-400'; // 恢复更清晰的灰色 - 未开始
|
||||
} else {
|
||||
return 'bg-red-500'; // 红色 - 失败
|
||||
return 'bg-rose-500'; // 恢复更清晰的红色 - 失败
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +50,9 @@
|
||||
const failed = downloadStatus.filter((status) => status !== 7 && status !== 0).length;
|
||||
|
||||
if (completed === total) {
|
||||
return { text: '全部完成', color: 'default' };
|
||||
return { text: '完成', color: 'outline' }; // 更简洁的文案
|
||||
} else if (failed > 0) {
|
||||
return { text: '部分失败', color: 'destructive' };
|
||||
return { text: '失败', color: 'destructive' };
|
||||
} else {
|
||||
return { text: '进行中', color: 'secondary' };
|
||||
}
|
||||
@@ -88,33 +86,30 @@
|
||||
// 根据模式确定显示的标题和副标题
|
||||
$: displayTitle = customTitle || video.name;
|
||||
$: displaySubtitle = customSubtitle || video.upper_name;
|
||||
$: showUserIcon = mode === 'default';
|
||||
$: cardClasses =
|
||||
mode === 'default'
|
||||
? 'group flex h-full min-w-0 flex-col transition-shadow hover:shadow-md'
|
||||
: 'transition-shadow hover:shadow-md';
|
||||
? 'group flex h-full min-w-0 flex-col transition-all hover:shadow-lg hover:shadow-primary/5 border-border/50'
|
||||
: 'transition-all hover:shadow-lg border-border/50';
|
||||
</script>
|
||||
|
||||
<Card class={cardClasses}>
|
||||
<CardHeader class={mode === 'default' ? 'flex-shrink-0 pb-3' : 'pb-3'}>
|
||||
<div class="flex min-w-0 items-start justify-between gap-2">
|
||||
<CardHeader class="flex-shrink-0 pb-3">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<CardTitle
|
||||
class="line-clamp-2 min-w-0 flex-1 cursor-default {mode === 'default'
|
||||
? 'text-base'
|
||||
: 'text-base'} leading-tight"
|
||||
? 'text-sm'
|
||||
: 'text-sm'} leading-relaxed font-medium"
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
</CardTitle>
|
||||
<Badge variant={overallStatus.color} class="shrink-0 text-xs">
|
||||
<Badge variant={overallStatus.color} class="shrink-0 px-2 py-1 text-xs font-medium">
|
||||
{overallStatus.text}
|
||||
</Badge>
|
||||
</div>
|
||||
{#if displaySubtitle}
|
||||
<div class="text-muted-foreground flex min-w-0 items-center gap-1 text-sm">
|
||||
{#if showUserIcon}
|
||||
<UserIcon class="h-3 w-3 shrink-0" />
|
||||
{/if}
|
||||
<div class="text-muted-foreground mt-1.5 flex min-w-0 items-center gap-1 text-sm">
|
||||
<UserIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 cursor-default truncate" title={displaySubtitle}>
|
||||
{displaySubtitle}
|
||||
</span>
|
||||
@@ -122,34 +117,30 @@
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0' : 'pt-0'}
|
||||
class={mode === 'default' ? 'flex min-w-0 flex-1 flex-col justify-end pt-0 pb-3' : 'pt-0 pb-4'}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- 进度条区域 -->
|
||||
{#if showProgress}
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="text-muted-foreground flex justify-between {mode === 'default'
|
||||
? 'text-xs'
|
||||
: 'text-xs'}"
|
||||
>
|
||||
<!-- 进度信息 -->
|
||||
<div class="text-muted-foreground flex justify-between text-sm font-medium">
|
||||
<span class="truncate">下载进度</span>
|
||||
<span class="shrink-0">{completed}/{total}</span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="flex w-full {gap}">
|
||||
<div class="flex w-full gap-0.5">
|
||||
{#each video.download_status as status, index (index)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex-1">
|
||||
<div
|
||||
class="{progressHeight} w-full cursor-help rounded-sm transition-all {getSegmentColor(
|
||||
class="h-1.5 w-full cursor-help rounded-full transition-all {getSegmentColor(
|
||||
status
|
||||
)}"
|
||||
)} hover:opacity-80"
|
||||
></div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{getTaskName(index)}: {getStatusText(status)}</p>
|
||||
<p class="text-sm">{getTaskName(index)}: {getStatusText(status)}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
@@ -157,13 +148,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
{#if showActions && mode === 'default'}
|
||||
<div class="flex min-w-0 gap-1.5">
|
||||
<div class="flex min-w-0 gap-1.5 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="min-w-0 flex-1 cursor-pointer px-2 text-xs"
|
||||
class="hover:bg-accent hover:text-accent-foreground h-8 min-w-0 flex-1 cursor-pointer px-2 text-xs font-medium"
|
||||
onclick={handleViewDetail}
|
||||
>
|
||||
<InfoIcon class="mr-1 h-3 w-3 shrink-0" />
|
||||
@@ -172,7 +162,7 @@
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="shrink-0 cursor-pointer px-2"
|
||||
class="hover:bg-accent hover:text-accent-foreground h-8 shrink-0 cursor-pointer px-2"
|
||||
onclick={() => (resetDialogOpen = true)}
|
||||
>
|
||||
<RotateCcwIcon class="h-3 w-3" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export class IsMobile extends MediaQuery {
|
||||
constructor() {
|
||||
super(`max-width: ${MOBILE_BREAKPOINT - 1}px`);
|
||||
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
|
||||
super(`max-width: ${breakpoint - 1}px`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { writable } from 'svelte/store';
|
||||
export interface BreadcrumbItem {
|
||||
href?: string;
|
||||
label: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const breadcrumbStore = writable<BreadcrumbItem[]>([]);
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ToQuery = (state: AppState): string => {
|
||||
params.set(videoSource.type, videoSource.id);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
return queryString ? `videos?${queryString}` : 'videos';
|
||||
};
|
||||
|
||||
export const setQuery = (query: string) => {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { type VideoSourcesResponse } from '$lib/types';
|
||||
|
||||
export const videoSourceStore = writable<VideoSourcesResponse | undefined>(undefined);
|
||||
|
||||
// 便捷的设置和清除方法
|
||||
export const setVideoSources = (sources: VideoSourcesResponse) => {
|
||||
videoSourceStore.set(sources);
|
||||
};
|
||||
|
||||
export const clearFilter = () => {
|
||||
videoSourceStore.set(undefined);
|
||||
};
|
||||
Reference in New Issue
Block a user