mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Redesign mobile sidebar
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
**Redesign mobile sidebar**
|
||||
|
||||
Mobile sidebar is now a separate component from the desktop one, with its own id `nd-sidebar-mobile`.
|
||||
|
||||
note to advanced use cases: Fumadocs UI now stopped using `fumadocs-core/sidebar`, avoid using the primitive directly as provider is not used.
|
||||
@@ -20,7 +20,7 @@ const docsOptions: DocsLayoutProps = {
|
||||
...option,
|
||||
icon: (
|
||||
<div
|
||||
className="rounded-md p-1 shadow-lg ring-2 [&_svg]:size-5"
|
||||
className="rounded-md p-1 shadow-lg ring-2 [&_svg]:size-6.5 md:[&_svg]:size-5"
|
||||
style={
|
||||
{
|
||||
color,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ElementType,
|
||||
type ReactNode,
|
||||
type ReactElement,
|
||||
type ComponentPropsWithoutRef,
|
||||
createContext,
|
||||
type ElementType,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { RemoveScroll } from 'react-remove-scroll';
|
||||
import { useMediaQuery } from './utils/use-media-query';
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | null>(null);
|
||||
|
||||
@@ -105,27 +105,14 @@ export function SidebarList<T extends ElementType = 'aside'>({
|
||||
...props
|
||||
}: SidebarContentProps<T>): ReactElement {
|
||||
const { open } = useSidebarContext();
|
||||
const [isBlocking, setIsBlocking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!removeScrollOn) return;
|
||||
const mediaQueryList = window.matchMedia(removeScrollOn);
|
||||
|
||||
const handleChange = (): void => {
|
||||
setIsBlocking(mediaQueryList.matches);
|
||||
};
|
||||
handleChange();
|
||||
mediaQueryList.addEventListener('change', handleChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [removeScrollOn]);
|
||||
const isBlocking =
|
||||
useMediaQuery(removeScrollOn ?? '', !removeScrollOn) ?? false;
|
||||
|
||||
return (
|
||||
<RemoveScroll
|
||||
as={as ?? 'aside'}
|
||||
data-open={open}
|
||||
enabled={Boolean(isBlocking && open)}
|
||||
enabled={isBlocking && open}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string, disabled = false): boolean | null {
|
||||
const [isMatch, setMatch] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
const mediaQueryList = window.matchMedia(query);
|
||||
|
||||
const handleChange = () => {
|
||||
setMatch(mediaQueryList.matches);
|
||||
};
|
||||
handleChange();
|
||||
mediaQueryList.addEventListener('change', handleChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [disabled, query]);
|
||||
|
||||
return isMatch;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
'src/search/orama-cloud.ts',
|
||||
'src/utils/use-on-change.ts',
|
||||
'src/utils/use-effect-event.ts',
|
||||
'src/utils/use-media-query.ts',
|
||||
'src/i18n/index.ts',
|
||||
'src/highlight/index.ts',
|
||||
'src/highlight/client.tsx',
|
||||
|
||||
@@ -17,7 +17,7 @@ Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `app/layout.config.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
|
||||
@@ -17,7 +17,7 @@ Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `app/layout.config.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
@theme {
|
||||
--animate-fd-fade-in: fd-fade-in 300ms ease;
|
||||
|
||||
--animate-fd-fade-out: fd-fade-out 300ms ease;
|
||||
|
||||
--animate-fd-dialog-in: fd-dialog-in 200ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
--animate-fd-dialog-out: fd-dialog-out 300ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
--animate-fd-popover-in: fd-popover-in 150ms ease;
|
||||
|
||||
--animate-fd-popover-out: fd-popover-out 150ms ease;
|
||||
|
||||
--animate-fd-collapsible-down: fd-collapsible-down 150ms ease-out;
|
||||
|
||||
--animate-fd-collapsible-up: fd-collapsible-up 150ms ease-out;
|
||||
|
||||
--animate-fd-accordion-down: fd-accordion-down 200ms ease-out;
|
||||
|
||||
--animate-fd-accordion-up: fd-accordion-up 200ms ease-out;
|
||||
|
||||
--animate-fd-nav-menu-in: fd-nav-menu-in 200ms ease;
|
||||
|
||||
--animate-fd-nav-menu-out: fd-nav-menu-out 200ms ease;
|
||||
|
||||
--animate-fd-enterFromLeft: fd-enterFromLeft 250ms ease;
|
||||
|
||||
--animate-fd-enterFromRight: fd-enterFromRight 250ms ease;
|
||||
|
||||
--animate-fd-exitToLeft: fd-exitToLeft 250ms ease;
|
||||
|
||||
--animate-fd-exitToRight: fd-exitToRight 250ms ease;
|
||||
|
||||
@keyframes fd-collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-dialog-in {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-dialog-out {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-popover-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-popover-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-enterFromRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(200px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-enterFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-exitToRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(200px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-exitToLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-nav-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
height: 0px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
height: var(--radix-navigation-menu-viewport-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-nav-menu-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
height: var(--radix-navigation-menu-viewport-height);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-1
@@ -1,5 +1,4 @@
|
||||
@import './shiki.css';
|
||||
@import './animations.css';
|
||||
|
||||
@source '../dist/**/*.js';
|
||||
@plugin '../dist/theme/typography/index.js';
|
||||
@@ -17,6 +16,196 @@
|
||||
--color-fd-diff-remove-symbol: rgb(230, 10, 100);
|
||||
--color-fd-diff-add: rgba(14, 180, 100, 0.12);
|
||||
--color-fd-diff-add-symbol: rgb(10, 200, 100);
|
||||
|
||||
--animate-fd-fade-in: fd-fade-in 300ms ease;
|
||||
--animate-fd-fade-out: fd-fade-out 300ms ease;
|
||||
|
||||
--animate-fd-dialog-in: fd-dialog-in 200ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-fd-dialog-out: fd-dialog-out 300ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
--animate-fd-popover-in: fd-popover-in 150ms ease;
|
||||
--animate-fd-popover-out: fd-popover-out 150ms ease;
|
||||
|
||||
--animate-fd-collapsible-down: fd-collapsible-down 150ms ease-out;
|
||||
--animate-fd-collapsible-up: fd-collapsible-up 150ms ease-out;
|
||||
|
||||
--animate-fd-accordion-down: fd-accordion-down 200ms ease-out;
|
||||
--animate-fd-accordion-up: fd-accordion-up 200ms ease-out;
|
||||
|
||||
--animate-fd-nav-menu-in: fd-nav-menu-in 200ms ease;
|
||||
--animate-fd-nav-menu-out: fd-nav-menu-out 200ms ease;
|
||||
|
||||
--animate-fd-enterFromLeft: fd-enterFromLeft 250ms ease;
|
||||
--animate-fd-enterFromRight: fd-enterFromRight 250ms ease;
|
||||
|
||||
--animate-fd-exitToLeft: fd-exitToLeft 250ms ease;
|
||||
--animate-fd-exitToRight: fd-exitToRight 250ms ease;
|
||||
|
||||
@keyframes fd-collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-dialog-in {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-dialog-out {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-popover-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-popover-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-enterFromRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(200px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-enterFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-exitToRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(200px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-exitToLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-nav-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
height: 0px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
height: var(--radix-navigation-menu-viewport-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fd-nav-menu-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
height: var(--radix-navigation-menu-viewport-height);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"@radix-ui/react-direction": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.12",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-presence": "^1.1.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
@@ -97,6 +98,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-remove-scroll": "^2.6.3",
|
||||
"tailwind-merge": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
import type { PageTree } from 'fumadocs-core/server';
|
||||
import { type ReactNode, type ComponentProps, useMemo } from 'react';
|
||||
import { type ComponentProps, type ReactNode, useMemo } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TreeContextProvider, useTreeContext } from 'fumadocs-ui/contexts/tree';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { useSearchContext } from 'fumadocs-ui/contexts/search';
|
||||
import { useSidebar } from 'fumadocs-ui/contexts/sidebar';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import * as SidebarPrimitive from 'fumadocs-core/sidebar';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
|
||||
export interface DocsLayoutProps {
|
||||
@@ -85,8 +84,7 @@ function Sidebar() {
|
||||
}, [root]);
|
||||
|
||||
return (
|
||||
<SidebarPrimitive.SidebarList
|
||||
removeScrollOn="(width < 768px)" // md
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed flex flex-col shrink-0 p-4 top-14 z-20 text-sm overflow-auto md:sticky md:h-[calc(100dvh-56px)] md:w-[300px]',
|
||||
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background',
|
||||
@@ -94,7 +92,7 @@ function Sidebar() {
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SidebarPrimitive.SidebarList>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,9 +97,9 @@ function Item(props: Option) {
|
||||
<>
|
||||
<>{props.icon}</>
|
||||
<div className="flex-1 text-start">
|
||||
<p className="text-sm font-medium">{props.title}</p>
|
||||
<p className="text-[15px] font-medium md:text-sm">{props.title}</p>
|
||||
{props.description ? (
|
||||
<p className="text-xs text-fd-muted-foreground">
|
||||
<p className="text-sm text-fd-muted-foreground md:text-xs">
|
||||
{props.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
import { ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import * as Base from 'fumadocs-core/sidebar';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FC,
|
||||
Fragment,
|
||||
@@ -33,8 +32,11 @@ import type {
|
||||
} from '@radix-ui/react-collapsible';
|
||||
import type { PageTree } from 'fumadocs-core/server';
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
|
||||
import { RemoveScroll } from 'react-remove-scroll';
|
||||
import { Presence } from '@radix-ui/react-presence';
|
||||
|
||||
export interface SidebarProps extends HTMLAttributes<HTMLElement> {
|
||||
export interface SidebarProps extends ComponentProps<'aside'> {
|
||||
/**
|
||||
* Open folders by default if their level is lower or equal to a specific level
|
||||
* (Starting from 1)
|
||||
@@ -49,6 +51,13 @@ export interface SidebarProps extends HTMLAttributes<HTMLElement> {
|
||||
* @defaultValue true
|
||||
*/
|
||||
prefetch?: boolean;
|
||||
|
||||
/**
|
||||
* Support collapsing the sidebar on desktop mode
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
interface InternalContext {
|
||||
@@ -76,22 +85,91 @@ const FolderContext = createContext<{
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
} | null>(null);
|
||||
|
||||
export function CollapsibleSidebar(props: SidebarProps) {
|
||||
const { collapsed } = useSidebar();
|
||||
export function Sidebar({
|
||||
defaultOpenLevel = 0,
|
||||
prefetch = true,
|
||||
collapsible = true,
|
||||
...props
|
||||
}: SidebarProps) {
|
||||
const { open, setOpen, collapsed } = useSidebar();
|
||||
const context = useMemo<InternalContext>(() => {
|
||||
return {
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
level: 1,
|
||||
};
|
||||
}, [defaultOpenLevel, prefetch]);
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
const timerRef = useRef(0);
|
||||
const closeTimeRef = useRef(0);
|
||||
// md
|
||||
const isMobile = useMediaQuery('(width < 768px)') ?? false;
|
||||
const state = open ? 'open' : 'closed';
|
||||
|
||||
useOnChange(collapsed, () => {
|
||||
setHover(false);
|
||||
closeTimeRef.current = Date.now() + 150;
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<RemoveScroll enabled={open}>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
data-state={state}
|
||||
className="fixed z-40 inset-0 bg-black/30 backdrop-blur-sm data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</Presence>
|
||||
<Presence present={open}>
|
||||
<aside
|
||||
id="nd-sidebar-mobile"
|
||||
{...props}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
'fixed flex flex-col ps-1 border-e start-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-enterFromLeft data-[state=closed]:animate-fd-exitToLeft',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Context.Provider value={context}>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
</aside>
|
||||
</Presence>
|
||||
</RemoveScroll>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
<aside
|
||||
id="nd-sidebar"
|
||||
{...props}
|
||||
data-collapsed={collapsed}
|
||||
className={cn(
|
||||
'sticky top-(--fd-sidebar-top) z-20 bg-fd-card text-sm h-(--fd-sidebar-height) max-md:hidden',
|
||||
collapsible && [
|
||||
'transition-all',
|
||||
collapsed &&
|
||||
'-me-(--fd-sidebar-width) -translate-x-(--fd-sidebar-offset) rtl:translate-x-(--fd-sidebar-offset)',
|
||||
collapsed && hover && 'z-50 translate-x-0',
|
||||
collapsed && !hover && 'opacity-0',
|
||||
],
|
||||
props.className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--fd-sidebar-offset': 'calc(var(--fd-sidebar-width) - 6px)',
|
||||
'--fd-sidebar-top':
|
||||
'calc(var(--fd-banner-height) + var(--fd-nav-height))',
|
||||
'--fd-sidebar-height':
|
||||
'calc(100dvh - var(--fd-banner-height) - var(--fd-nav-height))',
|
||||
...props.style,
|
||||
} as object
|
||||
}
|
||||
onPointerEnter={(e) => {
|
||||
if (
|
||||
!collapsible ||
|
||||
!collapsed ||
|
||||
e.pointerType === 'touch' ||
|
||||
closeTimeRef.current > Date.now()
|
||||
@@ -101,7 +179,7 @@ export function CollapsibleSidebar(props: SidebarProps) {
|
||||
setHover(true);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (!collapsed || e.pointerType === 'touch') return;
|
||||
if (!collapsible || !collapsed || e.pointerType === 'touch') return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
|
||||
timerRef.current = window.setTimeout(
|
||||
@@ -114,89 +192,34 @@ export function CollapsibleSidebar(props: SidebarProps) {
|
||||
: 500,
|
||||
);
|
||||
}}
|
||||
data-collapsed={collapsed}
|
||||
className={cn(
|
||||
'md:transition-all',
|
||||
collapsed &&
|
||||
'md:-me-(--fd-sidebar-width) md:-translate-x-(--fd-sidebar-offset) rtl:md:translate-x-(--fd-sidebar-offset)',
|
||||
collapsed && hover && 'z-50 md:translate-x-0',
|
||||
collapsed && !hover && 'md:opacity-0',
|
||||
props.className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--fd-sidebar-offset': 'calc(var(--fd-sidebar-width) - 6px)',
|
||||
...props.style,
|
||||
} as object
|
||||
}
|
||||
/>
|
||||
>
|
||||
<div className="flex w-(--fd-sidebar-width) h-full max-w-full flex-col ms-auto border-e">
|
||||
<Context.Provider value={context}>{props.children}</Context.Provider>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
defaultOpenLevel = 0,
|
||||
prefetch = true,
|
||||
inner,
|
||||
...props
|
||||
}: SidebarProps & { inner?: HTMLAttributes<HTMLDivElement> }) {
|
||||
const context = useMemo<InternalContext>(() => {
|
||||
return {
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
level: 1,
|
||||
};
|
||||
}, [defaultOpenLevel, prefetch]);
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<Base.SidebarList
|
||||
id="nd-sidebar"
|
||||
removeScrollOn="(width < 768px)" // md
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] z-20 bg-fd-card text-sm md:sticky md:h-(--fd-sidebar-height)',
|
||||
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background/80 max-md:text-[15px] max-md:backdrop-blur-lg max-md:data-[open=false]:invisible',
|
||||
props.className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...props.style,
|
||||
'--fd-sidebar-height':
|
||||
'calc(100dvh - var(--fd-banner-height) - var(--fd-nav-height))',
|
||||
} as object
|
||||
}
|
||||
>
|
||||
<div
|
||||
{...inner}
|
||||
className={cn(
|
||||
'flex size-full max-w-full flex-col pt-2 md:ms-auto md:w-(--fd-sidebar-width) md:border-e md:pt-4',
|
||||
inner?.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Base.SidebarList>
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarHeader(props: HTMLAttributes<HTMLDivElement>) {
|
||||
export function SidebarHeader(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-3 px-4 empty:hidden', props.className)}
|
||||
className={cn(
|
||||
'flex flex-col gap-3 px-4 max-md:pt-6 max-md:pb-4 empty:hidden',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFooter(props: HTMLAttributes<HTMLDivElement>) {
|
||||
export function SidebarFooter(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-col border-t px-4 py-3 empty:hidden',
|
||||
'flex flex-col border-t px-4 py-3 empty:hidden max-md:pb-6',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
@@ -220,14 +243,14 @@ export function SidebarViewport(props: ScrollAreaProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarSeparator(props: HTMLAttributes<HTMLParagraphElement>) {
|
||||
export function SidebarSeparator(props: ComponentProps<'p'>) {
|
||||
const { level } = useInternalContext();
|
||||
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 mb-2 px-2 text-sm font-medium empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'inline-flex items-center gap-2 mb-2 px-2 font-medium empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
@@ -272,7 +295,7 @@ export function SidebarItem({
|
||||
export function SidebarFolder({
|
||||
defaultOpen = false,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
}: ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -375,9 +398,7 @@ export function SidebarFolderContent(props: CollapsibleContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCollapseTrigger(
|
||||
props: ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
|
||||
const { collapsed, setCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -402,7 +423,7 @@ function useFolderContext() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useInternalContext(): InternalContext {
|
||||
function useInternalContext() {
|
||||
const ctx = useContext(Context);
|
||||
if (!ctx) throw new Error('<Sidebar /> component required.');
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createContext, usePathname } from 'fumadocs-core/framework';
|
||||
import { SidebarProvider as BaseProvider } from 'fumadocs-core/sidebar';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
|
||||
interface SidebarContext {
|
||||
@@ -59,9 +58,7 @@ export function SidebarProvider({
|
||||
[open, collapsed],
|
||||
)}
|
||||
>
|
||||
<BaseProvider open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
</BaseProvider>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, Sidebar as SidebarIcon, X } from 'lucide-react';
|
||||
import { Menu, Sidebar as SidebarIcon } from 'lucide-react';
|
||||
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import { useNav } from '@/contexts/layout';
|
||||
import { SidebarTrigger } from 'fumadocs-core/sidebar';
|
||||
import { SidebarCollapseTrigger } from '@/components/layout/sidebar';
|
||||
import { SearchToggle } from '@/components/layout/search-toggle';
|
||||
|
||||
@@ -19,7 +18,7 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
|
||||
id="nd-subnav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'sticky top-(--fd-banner-height) z-30 flex h-14 items-center px-4 border-b border-fd-foreground/10 transition-colors backdrop-blur-sm md:px-6',
|
||||
'sticky top-(--fd-banner-height) z-30 flex h-14 items-center px-4 border-b transition-colors backdrop-blur-sm md:px-6',
|
||||
(!isTransparent || open) && 'bg-fd-background/80',
|
||||
props.className,
|
||||
)}
|
||||
@@ -32,10 +31,10 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
|
||||
export function NavbarSidebarTrigger(
|
||||
props: ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
const { open } = useSidebar();
|
||||
const { setOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarTrigger
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
@@ -44,9 +43,10 @@ export function NavbarSidebarTrigger(
|
||||
}),
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
{open ? <X /> : <Menu />}
|
||||
</SidebarTrigger>
|
||||
<Menu />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Languages, Sidebar as SidebarIcon } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import {
|
||||
CollapsibleSidebar,
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
SidebarFooter,
|
||||
@@ -183,13 +182,12 @@ export function DocsLayoutSidebar({
|
||||
links?: LinkItemType[];
|
||||
nav?: ReactNode;
|
||||
}) {
|
||||
const Aside = collapsible ? CollapsibleSidebar : Sidebar;
|
||||
|
||||
return (
|
||||
<>
|
||||
{collapsible ? <CollapsibleControl /> : null}
|
||||
<Aside
|
||||
<Sidebar
|
||||
{...props}
|
||||
collapsible={collapsible}
|
||||
className={cn('md:ps-(--fd-layout-offset)', props.className)}
|
||||
>
|
||||
<SidebarHeader>
|
||||
@@ -222,7 +220,7 @@ export function DocsLayoutSidebar({
|
||||
<SidebarPageTree components={components} />
|
||||
</SidebarViewport>
|
||||
<SidebarFooter>{footer}</SidebarFooter>
|
||||
</Aside>
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ export const layoutVariables = {
|
||||
};
|
||||
|
||||
export interface SidebarOptions extends SidebarProps {
|
||||
collapsible?: boolean;
|
||||
components?: Partial<SidebarComponents>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Navbar(props: ComponentProps<'div'>) {
|
||||
id="nd-nav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed left-(--fd-nav-left) top-(--fd-banner-height) z-40 box-content w-full max-w-fd-container -translate-x-1/2 border-b border-fd-foreground/10 transition-colors lg:mt-2 lg:w-[calc(100%-1rem)] lg:rounded-2xl lg:border',
|
||||
'fixed left-(--fd-nav-left) top-(--fd-banner-height) z-40 box-content w-full max-w-fd-container -translate-x-1/2 border-b transition-colors lg:mt-2 lg:w-[calc(100%-1rem)] lg:rounded-2xl lg:border',
|
||||
value.length > 0 ? 'shadow-lg' : 'shadow-sm',
|
||||
(!isTransparent || value.length > 0) &&
|
||||
'bg-fd-background/80 backdrop-blur-lg',
|
||||
|
||||
@@ -3,9 +3,8 @@ import { cn } from '@/utils/cn';
|
||||
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import { useNav } from '@/contexts/layout';
|
||||
import { SidebarTrigger } from 'fumadocs-core/sidebar';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isActive } from '@/utils/is-active';
|
||||
@@ -39,10 +38,10 @@ export function Navbar({
|
||||
export function NavbarSidebarTrigger(
|
||||
props: ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
const { open } = useSidebar();
|
||||
const { setOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarTrigger
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
@@ -51,9 +50,10 @@ export function NavbarSidebarTrigger(
|
||||
}),
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
{open ? <X /> : <Menu />}
|
||||
</SidebarTrigger>
|
||||
<Menu />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Fragment, type HTMLAttributes, useMemo } from 'react';
|
||||
import { type BaseLayoutProps, getLinks, slot, slots } from '@/layouts/shared';
|
||||
import {
|
||||
CollapsibleSidebar,
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
SidebarFooter,
|
||||
@@ -65,7 +64,6 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
tabMode = 'sidebar',
|
||||
nav: { transparentMode, ...nav } = {},
|
||||
sidebar: {
|
||||
collapsible: sidebarCollapsible = true,
|
||||
tabs: tabOptions,
|
||||
banner: sidebarBanner,
|
||||
footer: sidebarFooter,
|
||||
@@ -84,8 +82,6 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
[tabOptions, props.tree],
|
||||
);
|
||||
|
||||
const Aside = sidebarCollapsible ? CollapsibleSidebar : Sidebar;
|
||||
|
||||
const variables = cn(
|
||||
'[--fd-nav-height:calc(var(--spacing)*14)] [--fd-tocnav-height:36px] md:[--fd-sidebar-width:286px] xl:[--fd-toc-width:286px] xl:[--fd-tocnav-height:0px]',
|
||||
tabs.length > 0 &&
|
||||
@@ -115,16 +111,15 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
...props.containerProps?.style,
|
||||
}}
|
||||
>
|
||||
<Aside
|
||||
<Sidebar
|
||||
{...sidebar}
|
||||
className={cn(
|
||||
'md:ps-(--fd-layout-offset)',
|
||||
navMode === 'top' ? 'bg-transparent' : 'md:[--fd-nav-height:0px]',
|
||||
navMode === 'top'
|
||||
? 'md:bg-transparent md:*:pt-2.5'
|
||||
: 'md:[--fd-nav-height:0px] md:*:pt-3.5',
|
||||
sidebar.className,
|
||||
)}
|
||||
inner={{
|
||||
className: cn(navMode === 'top' ? 'md:pt-2.5' : 'md:pt-3.5'),
|
||||
}}
|
||||
>
|
||||
<SidebarHeader>
|
||||
{navMode === 'auto' && (
|
||||
@@ -135,14 +130,14 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
{sidebarCollapsible && (
|
||||
{(sidebar.collapsible ?? true) && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'text-fd-muted-foreground mb-auto',
|
||||
}),
|
||||
'text-fd-muted-foreground mb-auto',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
@@ -195,7 +190,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
)}
|
||||
{sidebarFooter}
|
||||
</SidebarFooter>
|
||||
</Aside>
|
||||
</Sidebar>
|
||||
<DocsNavbar
|
||||
{...props}
|
||||
links={links}
|
||||
@@ -234,7 +229,7 @@ function DocsNavbar({
|
||||
<Navbar mode={navMode}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row border-b border-fd-foreground/10 px-4 h-14',
|
||||
'flex flex-row border-b px-4 h-14',
|
||||
navMode === 'auto' && 'md:px-6',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,6 @@ import Link from 'fumadocs-core/link';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useI18n } from './contexts/i18n';
|
||||
import { useTreeContext, useTreePath } from './contexts/tree';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import type { PageTree, TOCItemType } from 'fumadocs-core/server';
|
||||
import { createContext, usePathname } from 'fumadocs-core/framework';
|
||||
import {
|
||||
@@ -48,7 +47,7 @@ export function TocPopoverTrigger({
|
||||
[items, active],
|
||||
);
|
||||
const path = useTreePath().at(-1);
|
||||
const showCurrent = selected !== -1 && !open;
|
||||
const showItem = selected !== -1 && !open;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -63,16 +62,12 @@ export function TocPopoverTrigger({
|
||||
max={1}
|
||||
className={cn(open && 'text-fd-primary')}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'grid flex-1 *:row-start-1 *:col-start-1',
|
||||
open && 'text-fd-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="grid flex-1 *:row-start-1 *:col-start-1">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate transition-all',
|
||||
showCurrent && 'opacity-0 -translate-y-full pointer-events-none',
|
||||
open && 'text-fd-foreground',
|
||||
showItem && 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{path?.name ?? text.toc}
|
||||
@@ -80,7 +75,7 @@ export function TocPopoverTrigger({
|
||||
<span
|
||||
className={cn(
|
||||
'truncate transition-all',
|
||||
!showCurrent && 'opacity-0 translate-y-full pointer-events-none',
|
||||
!showItem && 'opacity-0 translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{items[selected]?.title}
|
||||
@@ -166,7 +161,6 @@ export function TocPopoverContent(props: ComponentProps<'div'>) {
|
||||
export function TocPopover(props: HTMLAttributes<HTMLDivElement>) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const sidebar = useSidebar();
|
||||
const { tocNav } = usePageStyles();
|
||||
const { isTransparent } = useNav();
|
||||
|
||||
@@ -209,10 +203,9 @@ export function TocPopover(props: HTMLAttributes<HTMLDivElement>) {
|
||||
id="nd-tocnav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'border-b border-fd-foreground/10 backdrop-blur-sm transition-colors',
|
||||
'border-b backdrop-blur-sm transition-colors',
|
||||
(!isTransparent || open) && 'bg-fd-background/80',
|
||||
open && 'shadow-lg',
|
||||
sidebar.open && 'max-md:hidden',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { TableOfContents } from 'fumadocs-core/server';
|
||||
import {
|
||||
type AnchorHTMLAttributes,
|
||||
type ComponentProps,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
@@ -46,7 +45,7 @@ type TableOfContentOptions = Omit<TOCProps, 'items' | 'children'> &
|
||||
type TableOfContentPopoverOptions = Omit<TableOfContentOptions, 'single'>;
|
||||
|
||||
interface EditOnGitHubOptions
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'children'> {
|
||||
extends Omit<ComponentProps<'a'>, 'href' | 'children'> {
|
||||
owner: string;
|
||||
repo: string;
|
||||
|
||||
|
||||
Generated
+9
-3
Reference in New Issue
Block a user