243 lines
6.0 KiB
TypeScript
243 lines
6.0 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react"
|
|
import { PanelLeftCloseIcon, ServerIcon } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet"
|
|
import {
|
|
ALL_SERVERS_ID,
|
|
ServerTileRail,
|
|
hostnameOf,
|
|
type ServerTileItem,
|
|
} from "@/components/server-tile-rail"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */
|
|
|
|
const STORAGE_KEY = "mm:server-rail-collapsed"
|
|
const DESKTOP_MQ = "(min-width: 768px)"
|
|
|
|
function readCollapsed(): boolean {
|
|
try {
|
|
return window.localStorage.getItem(STORAGE_KEY) === "1"
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function writeCollapsed(value: boolean) {
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0")
|
|
} catch {
|
|
// quota / private mode
|
|
}
|
|
}
|
|
|
|
interface ServerRailContextValue {
|
|
openRail: () => void
|
|
expandRail: () => void
|
|
collapsed: boolean
|
|
selectedLabel: string
|
|
}
|
|
|
|
const ServerRailContext = createContext<ServerRailContextValue | null>(null)
|
|
|
|
function useServerRail() {
|
|
const ctx = useContext(ServerRailContext)
|
|
if (!ctx) {
|
|
throw new Error("ServerRailMobileButton must be used inside ServerRailLayout")
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
function ServerRailMobileButton() {
|
|
const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
|
|
return (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={cn("min-w-0 max-w-full",collapsed ? undefined : "md:hidden")}
|
|
aria-label={collapsed ? "Показать список серверов" : "Серверы"}
|
|
onClick={() => {
|
|
if (window.matchMedia(DESKTOP_MQ).matches) {
|
|
expandRail()
|
|
return
|
|
}
|
|
openRail()
|
|
}}
|
|
>
|
|
<ServerIcon aria-hidden="true" data-icon="inline-start" />
|
|
<span className="min-w-0 truncate">{selectedLabel}</span>
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function ServerRailLayout({
|
|
items,
|
|
selectedId,
|
|
onSelect,
|
|
showAll = true,
|
|
allCount = 0,
|
|
showCount = true,
|
|
showType = true,
|
|
headerRight,
|
|
loading = false,
|
|
preserveSelection = false,
|
|
extra,
|
|
header,
|
|
banner,
|
|
contentClassName,
|
|
children,
|
|
}: {
|
|
items: ServerTileItem[]
|
|
selectedId: string
|
|
onSelect: (id: string) => void
|
|
showAll?: boolean
|
|
allCount?: number
|
|
showCount?: boolean
|
|
showType?: boolean
|
|
headerRight?: ReactNode
|
|
loading?: boolean
|
|
preserveSelection?: boolean
|
|
extra?: ReactNode
|
|
header?: ReactNode
|
|
banner?: ReactNode
|
|
contentClassName?: string
|
|
children: ReactNode
|
|
}) {
|
|
const [railOpen, setRailOpen] = useState(false)
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const [hydrated, setHydrated] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled=false
|
|
queueMicrotask(()=>{if(cancelled)return;setCollapsed(readCollapsed());setHydrated(true)})
|
|
return()=>{cancelled=true}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!hydrated) return
|
|
writeCollapsed(collapsed)
|
|
}, [collapsed, hydrated])
|
|
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
onSelect(id)
|
|
setRailOpen(false)
|
|
},
|
|
[onSelect],
|
|
)
|
|
|
|
const expandRail = useCallback(() => {
|
|
setCollapsed(false)
|
|
}, [])
|
|
|
|
const selectedLabel = useMemo(() => {
|
|
if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы"
|
|
const item = items.find((entry) => entry.id === selectedId)
|
|
return item ? hostnameOf(item) : "Сервер"
|
|
}, [items, selectedId, showAll])
|
|
|
|
const ctx = useMemo<ServerRailContextValue>(
|
|
() => ({
|
|
openRail: () => setRailOpen(true),
|
|
expandRail,
|
|
collapsed,
|
|
selectedLabel,
|
|
}),
|
|
[collapsed, expandRail, selectedLabel],
|
|
)
|
|
|
|
const railHeaderRight = (
|
|
<>
|
|
{headerRight}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="hidden md:inline-flex"
|
|
aria-label="Свернуть список серверов"
|
|
onClick={() => setCollapsed(true)}
|
|
>
|
|
<PanelLeftCloseIcon />
|
|
</Button>
|
|
</>
|
|
)
|
|
|
|
const rail = (
|
|
<ServerTileRail
|
|
items={items}
|
|
selectedId={selectedId}
|
|
onSelect={handleSelect}
|
|
showAll={showAll}
|
|
allCount={allCount}
|
|
showCount={showCount}
|
|
showType={showType}
|
|
headerRight={railHeaderRight}
|
|
loading={loading}
|
|
preserveSelection={preserveSelection}
|
|
className="min-h-0 flex-1"
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<ServerRailContext.Provider value={ctx}>
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
{header}
|
|
{banner}
|
|
<div className="flex min-h-0 flex-1">
|
|
{!collapsed ? (
|
|
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
|
{rail}
|
|
{extra}
|
|
</aside>
|
|
) : null}
|
|
<div className={cn("min-w-0 flex-1 overflow-y-auto p-4 md:p-6", contentClassName)}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
|
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
|
<SheetHeader className="px-1 pt-1">
|
|
<SheetTitle>Серверы</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
<ServerTileRail
|
|
items={items}
|
|
selectedId={selectedId}
|
|
onSelect={handleSelect}
|
|
showAll={showAll}
|
|
allCount={allCount}
|
|
showCount={showCount}
|
|
showType={showType}
|
|
showHeader={false}
|
|
headerRight={headerRight}
|
|
loading={loading}
|
|
preserveSelection={preserveSelection}
|
|
className="min-h-0 flex-1"
|
|
/>
|
|
{extra}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
</ServerRailContext.Provider>
|
|
)
|
|
}
|
|
|
|
export { ServerRailLayout, ServerRailMobileButton }
|