img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..488fb33
--- /dev/null
+++ b/src/components/ui/collapsible.tsx
@@ -0,0 +1,21 @@
+"use client"
+
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
+
+function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
+ return
+}
+
+function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..5cadf3f
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+import { cn } from "cn"
+
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..cafe6ba
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,267 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+import { cn } from "cn"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx
new file mode 100644
index 0000000..e11ce34
--- /dev/null
+++ b/src/components/ui/empty.tsx
@@ -0,0 +1,103 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+function Empty({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+const emptyMediaVariants = cva(
+ "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function EmptyMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Empty,
+ EmptyHeader,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+ EmptyMedia,
+}
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..08cd6aa
--- /dev/null
+++ b/src/components/ui/input-group.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+ "inline-end":
+ "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+ "block-start":
+ "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+ "block-end":
+ "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "flex items-center gap-2 text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset"
+ }) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..b7f61b3
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "cn"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..5141de7
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,17 @@
+import * as React from "react"
+import { cn } from "cn"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx
new file mode 100644
index 0000000..fc7757c
--- /dev/null
+++ b/src/components/ui/toggle-group.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
+import { type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 2,
+ orientation: "horizontal",
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 2,
+ orientation = "horizontal",
+ children,
+ ...props
+}: ToggleGroupPrimitive.Props &
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
new file mode 100644
index 0000000..f38073f
--- /dev/null
+++ b/src/components/ui/toggle.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+const toggleVariants = cva(
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border border-input bg-transparent hover:bg-muted",
+ },
+ size: {
+ default:
+ "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..3f9a713
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+import { cn } from "cn"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/constants/auth.ts b/src/constants/auth.ts
new file mode 100644
index 0000000..2395a9b
--- /dev/null
+++ b/src/constants/auth.ts
@@ -0,0 +1,8 @@
+/** 登录态 cookie:保存用户输入的 ACCESS_TOKEN,httpOnly 仅服务端可读 */
+export const SESSION_COOKIE = "torbox_admin_token";
+
+/** 登录有效期:30 天(秒) */
+export const SESSION_MAX_AGE = 60 * 60 * 24 * 30;
+
+/** 登录接口路径(相对 axios baseURL) */
+export const LOGIN_API = "/auth/login";
diff --git a/src/constants/torbox.ts b/src/constants/torbox.ts
index b40a2ce..09397ed 100644
--- a/src/constants/torbox.ts
+++ b/src/constants/torbox.ts
@@ -1,3 +1,5 @@
+import type { SourceType } from "@/types/torbox";
+
export const TORBOX_API = "https://api.torbox.app/v1/api";
export const SOURCES = [
@@ -16,3 +18,9 @@ export const VIDEO_EXT = new Set([
"mpg", "mpeg", "mpe", "ts", "m2ts", "mts", "vob", "3gp", "3g2", "m3u8", "mpd",
"rm", "rmvb", "asf", "divx", "qt", "h264", "h265", "hevc", "av1"
]);
+
+export const SOURCE_LABELS: Record = {
+ torrents: "Torrent",
+ usenet: "Usenet",
+ webdl: "Web DL"
+};
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
new file mode 100644
index 0000000..11da242
--- /dev/null
+++ b/src/hooks/use-auth.ts
@@ -0,0 +1,28 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+
+import { login, logout } from "@/services/auth";
+
+export const useLogin = () => {
+ const router = useRouter();
+ return useMutation({
+ mutationFn: login,
+ onSuccess: () => {
+ router.replace("/");
+ router.refresh();
+ }
+ });
+};
+
+export const useLogout = () => {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: logout,
+ onSuccess: () => {
+ queryClient.clear();
+ router.replace("/login");
+ router.refresh();
+ }
+ });
+};
diff --git a/src/hooks/use-copy.ts b/src/hooks/use-copy.ts
new file mode 100644
index 0000000..6ecb455
--- /dev/null
+++ b/src/hooks/use-copy.ts
@@ -0,0 +1,14 @@
+import { useCallback } from "react";
+
+import { message } from "@/lib/message";
+import { copyText } from "@/utils/clipboard";
+
+export const useCopy = () =>
+ useCallback(async (text: string, successText = "已复制到剪贴板") => {
+ try {
+ await copyText(text);
+ message.success(successText);
+ } catch {
+ message.error("复制失败,请手动复制");
+ }
+ }, []);
diff --git a/src/hooks/use-element-width.ts b/src/hooks/use-element-width.ts
new file mode 100644
index 0000000..5c79096
--- /dev/null
+++ b/src/hooks/use-element-width.ts
@@ -0,0 +1,21 @@
+import { useCallback, useState } from "react";
+
+const getContentWidth = (node: HTMLElement) => {
+ const style = getComputedStyle(node);
+ return node.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
+};
+
+/** 元素内容区宽度(不含内边距),挂载时同步测量,之后随 ResizeObserver 更新 */
+export const useElementWidth = () => {
+ const [width, setWidth] = useState(0);
+
+ const ref = useCallback((node: T | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
+ observer.observe(node);
+ setWidth(getContentWidth(node));
+ return () => observer.disconnect();
+ }, []);
+
+ return [ref, width] as const;
+};
diff --git a/src/hooks/use-library.ts b/src/hooks/use-library.ts
index 55b3b84..942ca3d 100644
--- a/src/hooks/use-library.ts
+++ b/src/hooks/use-library.ts
@@ -1,14 +1,98 @@
+import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getLibrary } from "@/services/library";
-import { useAuthStore } from "@/stores/auth-store";
+import { useLibraryFilterStore, type SourceFilter } from "@/stores/library-filter-store";
+import type { LibraryFile, LibraryItem } from "@/types/torbox";
+import { getBaseName, getFileExt, getMediaKind, type MediaKind } from "@/utils/media";
-export const useLibrary = () => {
- const token = useAuthStore((state) => state.token);
- return useQuery({
- queryKey: ["library", token],
+export type LibraryFileView = LibraryFile & {
+ /** 列表展示名(short_name 优先) */
+ displayName: string;
+ ext: string;
+ kind: MediaKind;
+};
+
+export type LibraryItemView = Omit & {
+ files: LibraryFileView[];
+ totalSize: number;
+};
+
+export const useLibrary = () =>
+ useQuery({
+ queryKey: ["library"],
queryFn: getLibrary,
- enabled: Boolean(token),
retry: false
});
+
+const sumSize = (files: LibraryFile[]) =>
+ files.reduce((total, file) => total + (file.size ?? 0), 0);
+
+const KIND_ORDER: Record = { video: 0, image: 1, other: 2 };
+
+/** 视频优先,其次图片、其他;同类按文件名自然排序(S01E02 排在 S01E10 前) */
+const byKindThenName = (a: LibraryFileView, b: LibraryFileView) =>
+ KIND_ORDER[a.kind] - KIND_ORDER[b.kind] ||
+ a.displayName.localeCompare(b.displayName, undefined, { numeric: true });
+
+/** 资源列表 + 工具栏筛选:关键词匹配资源名或文件名,类型过滤文件,来源过滤资源 */
+export const useFilteredLibrary = () => {
+ const query = useLibrary();
+ const keyword = useLibraryFilterStore((state) => state.keyword);
+ const source = useLibraryFilterStore((state) => state.source);
+ const kind = useLibraryFilterStore((state) => state.kind);
+
+ const items = useMemo(
+ () =>
+ (query.data?.items ?? []).map((item) => {
+ const files = item.files
+ .map((file) => {
+ const displayName = getBaseName(file.short_name ?? file.name);
+ return { ...file, displayName, ext: getFileExt(displayName), kind: getMediaKind(displayName) };
+ })
+ .sort(byKindThenName);
+ return { ...item, files, totalSize: sumSize(files) };
+ }),
+ [query.data]
+ );
+
+ const view = useMemo(() => {
+ const needle = keyword.trim().toLowerCase();
+
+ // 先按关键词与类型筛选,来源计数基于这一步的结果
+ const matched = items.flatMap((item) => {
+ const nameHit = !needle || item.name.toLowerCase().includes(needle);
+ const files = item.files.filter(
+ (file) =>
+ (kind === "all" || file.kind === kind) &&
+ (nameHit || file.name.toLowerCase().includes(needle))
+ );
+ const keep = files.length > 0 || (nameHit && kind === "all");
+ return keep ? [{ ...item, files, totalSize: sumSize(files) }] : [];
+ });
+
+ const sourceCounts: Record = {
+ all: matched.length,
+ torrents: 0,
+ usenet: 0,
+ webdl: 0
+ };
+ for (const item of matched) sourceCounts[item.source] += 1;
+
+ const visible = source === "all" ? matched : matched.filter((item) => item.source === source);
+
+ return {
+ items: visible,
+ sourceCounts,
+ summary: {
+ items: visible.length,
+ files: visible.reduce((total, item) => total + item.files.length, 0),
+ size: visible.reduce((total, item) => total + item.totalSize, 0)
+ },
+ isFiltered: Boolean(needle) || source !== "all" || kind !== "all",
+ isEmptyLibrary: items.length === 0
+ };
+ }, [items, keyword, source, kind]);
+
+ return { query, ...view };
};
diff --git a/src/hooks/use-origin.ts b/src/hooks/use-origin.ts
new file mode 100644
index 0000000..2f1f8bf
--- /dev/null
+++ b/src/hooks/use-origin.ts
@@ -0,0 +1,11 @@
+import { useSyncExternalStore } from "react";
+
+const subscribe = () => () => {};
+
+/** 当前站点 origin;服务端渲染阶段返回空字符串,避免直接在 render 中访问 window */
+export const useOrigin = () =>
+ useSyncExternalStore(
+ subscribe,
+ () => window.location.origin,
+ () => ""
+ );
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
new file mode 100644
index 0000000..09211d1
--- /dev/null
+++ b/src/instrumentation.ts
@@ -0,0 +1,7 @@
+export async function register() {
+ // 仅在 Node.js 服务端运行时校验,启动失败即暴露配置问题
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ const { assertServerEnv } = await import("@/server/env");
+ assertServerEnv();
+ }
+}
diff --git a/src/lib/axios.ts b/src/lib/axios.ts
index 0521643..c7b970e 100644
--- a/src/lib/axios.ts
+++ b/src/lib/axios.ts
@@ -1,25 +1,13 @@
import axios from "axios";
-import { useAuthStore } from "@/stores/auth-store";
+import { LOGIN_API } from "@/constants/auth";
import { message } from "./message";
+// 同源请求自动携带 httpOnly 登录 cookie,无需手动注入 token
const BaseClientService = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api",
timeout: 300000
});
-// 请求拦截器
-BaseClientService.interceptors.request.use(
- (config) => {
- const token = useAuthStore.getState().token;
- if (token) config.headers.Authorization = `Bearer ${token}`;
- return config;
- },
-
- (error) => {
- return Promise.reject(error);
- }
-);
-
// 响应拦截器
BaseClientService.interceptors.response.use(
(response) => {
@@ -27,6 +15,13 @@ BaseClientService.interceptors.response.use(
},
(error) => {
+ if (error.response?.status === 401) {
+ // 登录接口的 401 由登录表单就地提示;其余 401 说明登录态失效,整页跳回登录页以清空客户端缓存
+ // eslint-disable-next-line @next/next/no-location-assign-relative-destination -- 拦截器不在 React 组件内,无法使用 router
+ if (error.config?.url !== LOGIN_API) window.location.assign("/login");
+ return Promise.reject(error);
+ }
+
message.error(error.response?.data?.detail ?? error.message);
return Promise.reject(error);
diff --git a/src/proxy.ts b/src/proxy.ts
new file mode 100644
index 0000000..dbc59a5
--- /dev/null
+++ b/src/proxy.ts
@@ -0,0 +1,20 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { SESSION_COOKIE } from "@/constants/auth";
+import { verifyAccessToken } from "@/server/auth";
+
+const LOGIN_PATH = "/login";
+
+/** 页面级拦截:未登录统一跳登录页,已登录访问登录页则回首页;API 由各 route 自行鉴权 */
+export function proxy(req: NextRequest) {
+ const authed = verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value);
+ const isLoginPage = req.nextUrl.pathname === LOGIN_PATH;
+
+ if (!authed && !isLoginPage) return NextResponse.redirect(new URL(LOGIN_PATH, req.url));
+ if (authed && isLoginPage) return NextResponse.redirect(new URL("/", req.url));
+ return NextResponse.next();
+}
+
+export const config = {
+ // 排除 API、Next 静态资源与带扩展名的公共文件
+ matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"]
+};
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 74fbc67..d7a4f45 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -1,4 +1,7 @@
import { timingSafeEqual } from "node:crypto";
+import type { NextRequest } from "next/server";
+import { SESSION_COOKIE } from "@/constants/auth";
+import { serverEnv } from "@/server/env";
const safeEqual = (a: string, b: string) => {
const x = Buffer.from(a);
@@ -6,24 +9,26 @@ const safeEqual = (a: string, b: string) => {
return x.length === y.length && timingSafeEqual(x, y);
};
-/** 校验 Bearer 头或 ?token= 与环境变量 ACCESS_TOKEN 是否一致 */
-export const isAuthorized = (req: Request) => {
- const expected = process.env.ACCESS_TOKEN;
- if (!expected) return false;
+/** 校验传入值是否等于环境变量 ACCESS_TOKEN */
+export const verifyAccessToken = (value: string | null | undefined): value is string =>
+ Boolean(value) && safeEqual(value!, serverEnv("ACCESS_TOKEN"));
+
+/** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */
+export const isAuthorized = (req: NextRequest) => {
const header = req.headers.get("authorization") ?? "";
const bearer = header.startsWith("Bearer ") ? header.slice(7) : "";
- const query = new URL(req.url).searchParams.get("token") ?? "";
- return safeEqual(bearer, expected) || safeEqual(query, expected);
+ return (
+ verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value) ||
+ verifyAccessToken(bearer) ||
+ verifyAccessToken(req.nextUrl.searchParams.get("token"))
+ );
};
export const jsonError = (status: number, detail: string) =>
Response.json({ success: false, detail }, { status, headers: { "Cache-Control": "no-store" } });
/** 统一入口:返回 Response 表示已拒绝,返回 null 表示通过 */
-export const guard = (req: Request) => {
- if (!process.env.ACCESS_TOKEN || !process.env.TORBOX_API_KEY) {
- return jsonError(500, "服务端未配置 ACCESS_TOKEN 或 TORBOX_API_KEY");
- }
+export const guard = (req: NextRequest) => {
if (!isAuthorized(req)) return jsonError(401, "bad token");
return null;
};
diff --git a/src/server/env.ts b/src/server/env.ts
new file mode 100644
index 0000000..bc70472
--- /dev/null
+++ b/src/server/env.ts
@@ -0,0 +1,14 @@
+const REQUIRED_ENV = ["ACCESS_TOKEN", "TORBOX_API_KEY"] as const;
+
+type RequiredEnv = (typeof REQUIRED_ENV)[number];
+
+/** 启动时校验必要的服务端密钥,缺失则抛错终止启动(错误信息不包含任何密钥值) */
+export const assertServerEnv = () => {
+ const missing = REQUIRED_ENV.filter((name) => !process.env[name]?.trim());
+ if (missing.length > 0) {
+ throw new Error(`缺少必要的环境变量:${missing.join("、")}。请在 .env.local 或容器环境中配置后重启`);
+ }
+};
+
+/** 读取已在启动时校验过的密钥;仅限服务端使用 */
+export const serverEnv = (name: RequiredEnv) => process.env[name] as string;
diff --git a/src/server/session.ts b/src/server/session.ts
new file mode 100644
index 0000000..9e861a5
--- /dev/null
+++ b/src/server/session.ts
@@ -0,0 +1,11 @@
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { SESSION_COOKIE } from "@/constants/auth";
+import { verifyAccessToken } from "@/server/auth";
+
+/** 读取并校验登录 cookie,未登录则跳转登录页;返回的 token 用于拼接播放器直链 */
+export const requireSessionToken = async () => {
+ const token = (await cookies()).get(SESSION_COOKIE)?.value;
+ if (!verifyAccessToken(token)) redirect("/login");
+ return token;
+};
diff --git a/src/server/torbox.ts b/src/server/torbox.ts
index 95c02be..a0e8593 100644
--- a/src/server/torbox.ts
+++ b/src/server/torbox.ts
@@ -1,19 +1,24 @@
+import { serverEnv } from "@/server/env";
import { SOURCES, TORBOX_API } from "@/constants/torbox";
import type { LibraryItem, LibraryResponse, SourceType } from "@/types/torbox";
type RawFile = { id: number; name: string; short_name?: string; size?: number };
type RawItem = { id: number; name: string; files?: RawFile[] };
-const apiKey = () => process.env.TORBOX_API_KEY ?? "";
+const apiKey = () => serverEnv("TORBOX_API_KEY");
const torboxFetch = async (path: string) => {
const res = await fetch(TORBOX_API + path, {
- headers: { Authorization: `Bearer ${apiKey()}`, Accept: "application/json" },
+ headers: {
+ Authorization: `Bearer ${apiKey()}`,
+ Accept: "application/json"
+ },
signal: AbortSignal.timeout(15000),
cache: "no-store"
});
const body = await res.json().catch(() => ({}));
- if (!res.ok || body.success === false) throw new Error(body.detail ?? res.statusText);
+ if (!res.ok || body.success === false)
+ throw new Error(body.detail ?? res.statusText);
return body.data;
};
@@ -49,7 +54,11 @@ export const getLibrary = async (): Promise => {
};
/** 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端) */
-export const requestDownloadUrl = async (type: SourceType, id: string, fileId: string) => {
+export const requestDownloadUrl = async (
+ type: SourceType,
+ id: string,
+ fileId: string
+) => {
const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({
token: apiKey(),
diff --git a/src/services/auth.ts b/src/services/auth.ts
new file mode 100644
index 0000000..f0d8d7b
--- /dev/null
+++ b/src/services/auth.ts
@@ -0,0 +1,10 @@
+import { LOGIN_API } from "@/constants/auth";
+import BaseClientService from "@/lib/axios";
+
+export const login = async (token: string) => {
+ await BaseClientService.post(LOGIN_API, { token });
+};
+
+export const logout = async () => {
+ await BaseClientService.post("/auth/logout");
+};
diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts
deleted file mode 100644
index 3732252..0000000
--- a/src/stores/auth-store.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { create } from "zustand";
-import { persist } from "zustand/middleware";
-
-type AuthState = {
- token: string;
- setToken: (token: string) => void;
- clearToken: () => void;
-};
-
-export const useAuthStore = create()(
- persist(
- (set) => ({
- token: "",
- setToken: (token) => set({ token }),
- clearToken: () => set({ token: "" })
- }),
- { name: "torbox-admin-auth" }
- )
-);
diff --git a/src/stores/library-filter-store.ts b/src/stores/library-filter-store.ts
new file mode 100644
index 0000000..3852292
--- /dev/null
+++ b/src/stores/library-filter-store.ts
@@ -0,0 +1,28 @@
+import { create } from "zustand";
+
+import type { SourceType } from "@/types/torbox";
+import type { MediaKind } from "@/utils/media";
+
+export type SourceFilter = "all" | SourceType;
+export type KindFilter = "all" | MediaKind;
+
+type LibraryFilterState = {
+ keyword: string;
+ source: SourceFilter;
+ kind: KindFilter;
+ setKeyword: (keyword: string) => void;
+ setSource: (source: SourceFilter) => void;
+ setKind: (kind: KindFilter) => void;
+ reset: () => void;
+};
+
+const initialFilter = { keyword: "", source: "all", kind: "all" } as const;
+
+/** 文件列表页的筛选条件(工具栏与列表共享),不持久化 */
+export const useLibraryFilterStore = create()((set) => ({
+ ...initialFilter,
+ setKeyword: (keyword) => set({ keyword }),
+ setSource: (source) => set({ source }),
+ setKind: (kind) => set({ kind }),
+ reset: () => set(initialFilter)
+}));
diff --git a/src/stores/library-view-store.ts b/src/stores/library-view-store.ts
new file mode 100644
index 0000000..9a07f09
--- /dev/null
+++ b/src/stores/library-view-store.ts
@@ -0,0 +1,43 @@
+import type { VirtualItem } from "@tanstack/react-virtual";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+/** 资源列表滚动快照:以首个可见行为锚点,数据增删后仍能回到原位置 */
+export type LibraryScrollSnapshot = {
+ /** 生成快照时的筛选条件,不一致时不恢复 */
+ signature: string;
+ anchorKey: string;
+ /** 滚动位置相对锚点行顶部的偏移 */
+ anchorDelta: number;
+ /** 已测量行的尺寸,恢复时作为 initialMeasurementsCache */
+ measurements: VirtualItem[];
+};
+
+type LibraryViewState = {
+ /** 已收起的资源(`${source}-${id}`) */
+ collapsed: string[];
+ snapshot: LibraryScrollSnapshot | null;
+ toggleCollapsed: (key: string) => void;
+ saveSnapshot: (snapshot: LibraryScrollSnapshot) => void;
+};
+
+/** 资源列表的界面状态,存于 sessionStorage:刷新页面后恢复折叠与滚动位置 */
+export const useLibraryViewStore = create()(
+ persist(
+ (set) => ({
+ collapsed: [],
+ snapshot: null,
+ toggleCollapsed: (key) =>
+ set((state) => ({
+ collapsed: state.collapsed.includes(key)
+ ? state.collapsed.filter((item) => item !== key)
+ : [...state.collapsed, key]
+ })),
+ saveSnapshot: (snapshot) => set({ snapshot })
+ }),
+ {
+ name: "torbox-admin-library-view",
+ storage: createJSONStorage(() => sessionStorage)
+ }
+ )
+);
diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts
new file mode 100644
index 0000000..efd5cf0
--- /dev/null
+++ b/src/utils/clipboard.ts
@@ -0,0 +1,16 @@
+/** 兼容非安全上下文(局域网 http 部署时 navigator.clipboard 不可用)的复制 */
+export const copyText = async (text: string) => {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "");
+ textarea.style.opacity = "0";
+ document.body.append(textarea);
+ textarea.select();
+ const copied = document.execCommand("copy");
+ textarea.remove();
+ if (!copied) throw new Error("copy failed");
+};
diff --git a/src/utils/format.ts b/src/utils/format.ts
new file mode 100644
index 0000000..6ee44ef
--- /dev/null
+++ b/src/utils/format.ts
@@ -0,0 +1,16 @@
+const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
+
+/** 以 1024 进制格式化字节数,如 4.3 GB;未知大小返回 "—" */
+export const formatBytes = (bytes?: number) => {
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "—";
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value.toFixed(unit === 0 || value >= 100 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
+};
+
+/** 千分位整数,如 1,024 */
+export const formatCount = (value: number) => value.toLocaleString("en-US");
diff --git a/src/utils/media.ts b/src/utils/media.ts
index fc73d6f..a8dafbf 100644
--- a/src/utils/media.ts
+++ b/src/utils/media.ts
@@ -2,10 +2,18 @@ import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox";
export type MediaKind = "video" | "image" | "other";
-export const getMediaKind = (name: string): MediaKind => {
- const base = name.split(/[\\/]/).pop() ?? "";
+/** 取路径最后一段作为文件名 */
+export const getBaseName = (name: string) => name.split(/[\\/]/).pop() ?? "";
+
+/** 小写扩展名,没有扩展名时返回空字符串 */
+export const getFileExt = (name: string) => {
+ const base = getBaseName(name);
const dot = base.lastIndexOf(".");
- const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
+};
+
+export const getMediaKind = (name: string): MediaKind => {
+ const ext = getFileExt(name);
if (VIDEO_EXT.has(ext)) return "video";
if (IMAGE_EXT.has(ext)) return "image";
return "other";
@@ -26,3 +34,10 @@ export const buildStreamUrl = (
};
export const toPotPlayerUrl = (streamUrl: string) => `potplayer://${streamUrl}`;
+
+/** 全部资源的 M3U 播放列表地址,供 PotPlayer 等播放器直接打开 */
+export const buildPlaylistUrl = (origin: string, token: string) => {
+ const url = new URL("/api/playlist", origin);
+ url.searchParams.set("token", token);
+ return url.toString();
+};