Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,978 changes: 99 additions & 1,879 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,15 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"flag-icons": "^7.5.0",
"lucide-react": "^1.34.0",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
"simple-icons": "^16.31.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
Expand Down
41 changes: 34 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useEffect, useState, type ReactNode } from "react"
import { lazy, Suspense, useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from "react"
import { ArrowUp, ChartLine, House, Moon, Sun, UserRound, type LucideIcon } from "lucide-react"

import { NodePicker } from "@/components/NodePicker"
Expand All @@ -15,16 +15,43 @@ type Me = { authed: boolean; github: boolean; site_name: string; public_page: bo
const loadDetail = () => import("@/components/NodeDetail").then((m) => ({ default: m.NodeDetail }))
const NodeDetail = lazy(loadDetail)

const DARK_MEDIA = matchMedia("(prefers-color-scheme: dark)")

/**
* The visitor's own choice, or the system's while there is none. Only the toggle
* writes the choice down: persisting the system's answer on load would pin it,
* leaving a visitor who never touched the toggle in whichever mode their system
* happened to be in that day. The panel at `/admin/` shares this key on one
* origin, so it has to hold to the same rule -- one app writing on load pins the
* others.
*
* The system's answer is subscribed to rather than copied into state: a flip
* landing between the first render and the effect that would have attached the
* listener is otherwise never heard, and the next one is a day away.
*/
function useTheme() {
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem("theme")
return saved ? saved === "dark" : matchMedia("(prefers-color-scheme: dark)").matches
})
const [saved, setSaved] = useState(() => localStorage.getItem("theme"))
const system = useSyncExternalStore(
(notify) => {
DARK_MEDIA.addEventListener("change", notify)
return () => DARK_MEDIA.removeEventListener("change", notify)
},
() => DARK_MEDIA.matches,
)
const dark = saved ? saved === "dark" : system

useEffect(() => {
document.documentElement.classList.toggle("dark", dark)
localStorage.setItem("theme", dark ? "dark" : "light")
}, [dark])
return [dark, () => setDark((d) => !d)] as const

return [
dark,
() => {
const next = dark ? "light" : "dark"
localStorage.setItem("theme", next)
setSaved(next)
},
] as const
}

/** Kept in the corner rather than the header, as the classic layout does. */
Expand Down
66 changes: 19 additions & 47 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none 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 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
// The two shapes this theme renders: the filled button behind the retry on a
// failed load, and the ghost icon button in the corner toolbox. A variant nothing
// renders is dead styling, and `asChild` would carry a radix-ui dependency for a
// delegation no caller asks for.
const VARIANTS = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
}

const SIZES = { default: "h-9 px-4 py-2 has-[>svg]:px-3", icon: "size-9" }

function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"

}: React.ComponentProps<"button"> & { variant?: keyof typeof VARIANTS; size?: keyof typeof SIZES }) {
return (
<Comp
<button
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
className={cn(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
VARIANTS[variant],
SIZES[size],
className,
)}
{...props}
/>
)
}

export { Button, buttonVariants }
export { Button }
1 change: 0 additions & 1 deletion src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
@import "tailwindcss" source(none);
@source "../index.html";
@source "../src";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

Expand Down
2 changes: 1 addition & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export type Node = {
remark?: string
}

export class ApiError extends Error {
class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
Expand Down
4 changes: 1 addition & 3 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ export function monthUsage(node: { month_rx: number; month_tx: number; traffic_m
}
}

export const MODES: Record<string, string> = { up: "仅上行", down: "仅下行", max: "取较大值", sum: "上下行相加" }

export function percent(used: number, total: number): number {
return total > 0 ? Math.min(100, (used / total) * 100) : 0
}
Expand Down Expand Up @@ -155,7 +153,7 @@ const MDHHMM = new Intl.DateTimeFormat("zh-CN", {
minute: "2-digit",
})

export function clock(ms: number): string {
function clock(ms: number): string {
return HHMM.format(ms)
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function useNodeRoute() {
}

/** Announced as a popstate, so every `useNodeRoute` hears it the way it hears back. */
export function navigate(href: string) {
function navigate(href: string) {
history.pushState({}, "", href)
dispatchEvent(new PopStateEvent("popstate"))
scrollTo(0, 0)
Expand Down
Loading