feat: tags page

This commit is contained in:
monoid 2024-10-08 21:48:28 +09:00
parent 0bcfc9d74a
commit b4bae924ac
16 changed files with 1547 additions and 62 deletions

View File

@ -14,18 +14,24 @@
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-virtual": "^3.10.8", "@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dbtype": "workspace:dbtype", "dbtype": "workspace:dbtype",
"jotai": "^2.10.0", "jotai": "^2.10.0",
"lucide-react": "^0.451.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-resizable-panels": "^2.1.4", "react-resizable-panels": "^2.1.4",
"recharts": "^2.12.7",
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwind-merge": "^2.5.3", "tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -47,6 +53,7 @@
"shadcn-ui": "^0.8.0", "shadcn-ui": "^0.8.0",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^5.4.8" "vite": "^5.4.8",
"vitest": "^2.1.2"
} }
} }

View File

@ -15,6 +15,7 @@ import ContentInfoPage from "@/page/contentInfoPage.tsx";
import SettingPage from "@/page/settingPage.tsx"; import SettingPage from "@/page/settingPage.tsx";
import ComicPage from "@/page/reader/comicPage.tsx"; import ComicPage from "@/page/reader/comicPage.tsx";
import DifferencePage from "./page/differencePage.tsx"; import DifferencePage from "./page/differencePage.tsx";
import TagsPage from "./page/tagsPage.tsx";
const App = () => { const App = () => {
const { isDarkMode } = useTernaryDarkMode(); const { isDarkMode } = useTernaryDarkMode();
@ -40,6 +41,7 @@ const App = () => {
<Route path="/setting" component={SettingPage} /> <Route path="/setting" component={SettingPage} />
<Route path="/doc/:id/reader" component={ComicPage}/> <Route path="/doc/:id/reader" component={ComicPage}/>
<Route path="/difference" component={DifferencePage}/> <Route path="/difference" component={DifferencePage}/>
<Route path="/tags" component={TagsPage}/>
<Route component={NotFoundPage} /> <Route component={NotFoundPage} />
</Switch> </Switch>
</Layout> </Layout>

View File

@ -1,5 +1,5 @@
import { Link } from "wouter" import { Link } from "wouter"
import { MagnifyingGlassIcon, GearIcon, ActivityLogIcon, ArchiveIcon, PersonIcon } from "@radix-ui/react-icons" import { Search, Settings, Tags, ArchiveIcon, UserIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button.tsx" import { Button, buttonVariants } from "@/components/ui/button.tsx"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
import { useLogin } from "@/state/user.ts"; import { useLogin } from "@/state/user.ts";
@ -66,13 +66,13 @@ export function NavList() {
return <aside className="h-dvh flex flex-col"> return <aside className="h-dvh flex flex-col">
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1"> <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
{navItems && <>{navItems} <Separator/> </>} {navItems && <>{navItems} <Separator/> </>}
<NavItem icon={<MagnifyingGlassIcon className="h-5 w-5" />} to="/search" name="Search" /> <NavItem icon={<Search className="h-5 w-5" />} to="/search" name="Search" />
<NavItem icon={<ActivityLogIcon className="h-5 w-5" />} to="/tags" name="Tags" /> <NavItem icon={<Tags className="h-5 w-5" />} to="/tags" name="Tags" />
<NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" /> <NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
</nav> </nav>
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0"> <nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
<NavItem icon={<PersonIcon className="h-5 w-5" />} to={ loginInfo ? "/profile" : "/login"} name={ loginInfo ? "Profiles" : "Login"} /> <NavItem icon={<UserIcon className="h-5 w-5" />} to={ loginInfo ? "/profile" : "/login"} name={ loginInfo ? "Profiles" : "Login"} />
<NavItem icon={<GearIcon className="h-5 w-5" />} to="/setting" name="Settings" /> <NavItem icon={<Settings className="h-5 w-5" />} to="/setting" name="Settings" />
</nav> </nav>
</aside> </aside>
} }

View File

@ -0,0 +1,368 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,162 @@
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -1,5 +1,5 @@
import useSWRInifinite from "swr/infinite"; import useSWRInifinite from "swr/infinite";
import type { Document } from "dbtype/api"; import type { Document } from "dbtype";
import { fetcher } from "./fetcher"; import { fetcher } from "./fetcher";
import useSWR from "swr"; import useSWR from "swr";

View File

@ -5,5 +5,6 @@ export function useTags() {
return useSWR<{ return useSWR<{
name: string; name: string;
description: string; description: string;
}[]>("/api/tags", fetcher); occurs: number;
}[]>("/api/tags?withCount=true", fetcher);
} }

View File

@ -6,7 +6,7 @@ import { useGalleryDoc } from "@/hook/useGalleryDoc.ts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EnterFullScreenIcon, ExitFullScreenIcon, ExitIcon } from "@radix-ui/react-icons"; import { EnterFullScreenIcon, ExitFullScreenIcon, ExitIcon } from "@radix-ui/react-icons";
import { useEventListener } from "usehooks-ts"; import { useEventListener } from "usehooks-ts";
import type { Document } from "dbtype/api"; import type { Document } from "dbtype";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
interface ComicPageProps { interface ComicPageProps {

View File

@ -0,0 +1,93 @@
import { toPrettyTagname } from "@/components/gallery/TagBadge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTags } from "@/hook/useTags";
import { useState } from "react";
export function TagsPage() {
const { data, error, isLoading } = useTags();
const [searchInput, setSearchInput] = useState<string>("");
const [orderby, setOrderby] = useState<string>("name");
const [page, setPage] = useState<number>(1);
const pageSize = 10;
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error.message}</div>
}
const filteredTags = data?.filter(tag =>
tag.name.toLowerCase().includes(searchInput.toLowerCase())
).sort((a, b) => {
if (orderby === "name") {
return a.name.localeCompare(b.name);
} else if (orderby === "occurs") {
return b.occurs - a.occurs;
}
return 0;
});
const paginatedTags = filteredTags?.slice((page - 1) * pageSize, page * pageSize);
const totalPages = Math.ceil((filteredTags?.length || 0) / pageSize);
return (
<div className="p-4">
<div className="flex space-x-2">
<Select value={orderby} onValueChange={setOrderby}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="order by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="occurs">Occurs</SelectItem>
</SelectContent>
</Select>
<Input
className="mb-4"
placeholder="Search tags..."
value={searchInput}
onChange={(e) => {
setSearchInput(e.target.value);
setPage(1);
}}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-4 xl:grid-cols-3 gap-2 ">
{paginatedTags?.map(tag => (
<Card key={tag.name} className="mb-4">
<CardHeader>
<CardTitle className="text-2xl">{toPrettyTagname(tag.name)}({tag.occurs})</CardTitle>
</CardHeader>
<CardContent>
{tag.description}
</CardContent>
</Card>
))}
</div>
<div className="flex justify-between mt-4">
<Button onClick={() => setPage(page - 1)} disabled={page <= 1}>
</Button>
<Popover>
<PopoverTrigger asChild>
<span>{page} / {totalPages}</span>
</PopoverTrigger>
<PopoverContent>
<Input type="number" value={page}
max={totalPages} min={1}
onChange={(e) => setPage(Number(e.target.value))} />
</PopoverContent>
</Popover>
<Button onClick={() => setPage(page + 1)} disabled={page >= totalPages}>
</Button>
</div>
</div>
)
}
export default TagsPage;

View File

@ -1,56 +1,76 @@
import { Knex } from "knex"; import { Kysely, sql } from 'kysely';
export async function up(knex: Knex) { export async function up(db: Kysely<any>) {
await knex.schema.createTable("schema_migration", (b) => { await db.schema
b.string("version"); .createTable('schema_migration')
b.boolean("dirty"); .addColumn('version', 'char(16)')
}); .addColumn('dirty', 'boolean')
.execute();
await knex.schema.createTable("users", (b) => { await db.schema
b.string("username").primary().comment("user's login id"); .createTable('users')
b.string("password_hash", 64).notNullable(); .addColumn('username', 'varchar(256)', col => col.primaryKey())
b.string("password_salt", 64).notNullable(); .addColumn('password_hash', 'varchar(64)', col => col.notNull())
}); .addColumn('password_salt', 'varchar(64)', col => col.notNull())
await knex.schema.createTable("document", (b) => { .execute();
b.increments("id").primary();
b.string("title").notNullable(); await db.schema
b.string("content_type", 16).notNullable(); .createTable('document')
b.string("basepath", 256).notNullable().comment("directory path for resource"); .addColumn('id', 'serial', col => col.primaryKey())
b.string("filename", 256).notNullable().comment("filename"); .addColumn('title', 'varchar(512)', col => col.notNull())
b.string("content_hash").nullable(); .addColumn('content_type', 'varchar(16)', col => col.notNull())
b.json("additional").nullable(); .addColumn('basepath', 'varchar(256)', col => col.notNull())
b.integer("created_at").notNullable(); .addColumn('filename', 'varchar(512)', col => col.notNull())
b.integer("modified_at").notNullable(); .addColumn('content_hash', 'varchar')
b.integer("deleted_at"); .addColumn('additional', 'json')
b.index("content_type", "content_type_index"); .addColumn("pagenum", "integer", col => col.notNull())
}); .addColumn('created_at', 'integer', col => col.notNull())
await knex.schema.createTable("tags", (b) => { .addColumn('modified_at', 'integer', col => col.notNull())
b.string("name").primary(); .addColumn('deleted_at', 'integer')
b.text("description"); .execute();
});
await knex.schema.createTable("doc_tag_relation", (b) => { await db.schema
b.integer("doc_id").unsigned().notNullable(); .createTable('tags')
b.string("tag_name").notNullable(); .addColumn('name', 'varchar', col => col.primaryKey())
b.foreign("doc_id").references("document.id"); .addColumn('description', 'text')
b.foreign("tag_name").references("tags.name"); .execute();
b.primary(["doc_id", "tag_name"]);
}); await db.schema
await knex.schema.createTable("permissions", (b) => { .createTable('doc_tag_relation')
b.string("username").notNullable(); .addColumn('doc_id', 'integer', col => col.notNull())
b.string("name").notNullable(); .addColumn('tag_name', 'varchar', col => col.notNull())
b.primary(["username", "name"]); .addForeignKeyConstraint('doc_id_fk', ['doc_id'], 'document', ['id'])
b.foreign("username").references("users.username"); .addForeignKeyConstraint('tag_name_fk', ['tag_name'], 'tags', ['name'])
}); .addPrimaryKeyConstraint('doc_tag_relation_pk', ['doc_id', 'tag_name'])
// create admin account. .execute();
await knex
.insert({ await db.schema
username: "admin", .createTable('permissions')
password_hash: "unchecked", .addColumn('username', 'varchar', col => col.notNull())
password_salt: "unchecked", .addColumn('name', 'varchar', col => col.notNull())
.addPrimaryKeyConstraint('permissions_pk', ['username', 'name'])
.addForeignKeyConstraint('username_fk', ['username'], 'users', ['username'])
.execute();
// create admin account.
await db
.insertInto('users')
.values({
username: 'admin',
password_hash: 'unchecked',
password_salt: 'unchecked',
})
.execute();
await db
.insertInto('schema_migration')
.values({
version: '0.0.1',
dirty: false,
}) })
.into("users"); .execute();
} }
export async function down(knex: Knex) { export async function down(db: Kysely<any>) {
throw new Error("Downward migrations are not supported. Restore from backup."); throw new Error('Downward migrations are not supported. Restore from backup.');
} }

View File

@ -12,8 +12,14 @@ class SqliteTagAccessor implements TagAccessor {
.select("tag_name") .select("tag_name")
.select(qb => qb.fn.count<number>("doc_id").as("occurs")) .select(qb => qb.fn.count<number>("doc_id").as("occurs"))
.groupBy("tag_name") .groupBy("tag_name")
.innerJoin("tags", "tags.name", "doc_tag_relation.tag_name")
.select("tags.description")
.execute(); .execute();
return result; return result.map((x) => ({
name: x.tag_name,
description: x.description ?? undefined,
occurs: x.occurs,
}));
} }
async getAllTagList(): Promise<Tag[]> { async getAllTagList(): Promise<Tag[]> {
return (await this.kysely.selectFrom("tags") return (await this.kysely.selectFrom("tags")

View File

@ -4,7 +4,8 @@ export interface Tag {
} }
export interface TagCount { export interface TagCount {
tag_name: string; name: string;
description?: string;
occurs: number; occurs: number;
} }

File diff suppressed because it is too large Load Diff