mirror of
https://github.com/dathere/ckanaction.git
synced 2025-12-19 08:49:26 +00:00
feat: better home page
This commit is contained in:
parent
261b4ba21a
commit
1de7c29050
15 changed files with 1251 additions and 99 deletions
256
docs/components/codeblock.tsx
Normal file
256
docs/components/codeblock.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
|
||||
import { Check, Clipboard } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
import { mergeRefs } from "../lib/merge-refs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs.unstyled";
|
||||
import { buttonVariants } from "./ui/button";
|
||||
|
||||
export interface CodeBlockProps extends ComponentProps<"figure"> {
|
||||
/**
|
||||
* Icon of code block
|
||||
*
|
||||
* When passed as a string, it assumes the value is the HTML of icon
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
|
||||
/**
|
||||
* Allow to copy code with copy button
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
allowCopy?: boolean;
|
||||
|
||||
/**
|
||||
* Keep original background color generated by Shiki or Rehype Code
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
keepBackground?: boolean;
|
||||
|
||||
viewportProps?: HTMLAttributes<HTMLElement>;
|
||||
|
||||
/**
|
||||
* show line numbers
|
||||
*/
|
||||
"data-line-numbers"?: boolean;
|
||||
|
||||
/**
|
||||
* @defaultValue 1
|
||||
*/
|
||||
"data-line-numbers-start"?: number;
|
||||
|
||||
Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode;
|
||||
}
|
||||
|
||||
const TabsContext = createContext<{
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
nested: boolean;
|
||||
} | null>(null);
|
||||
|
||||
export function Pre(props: ComponentProps<"pre">) {
|
||||
return (
|
||||
<pre
|
||||
{...props}
|
||||
className={cn("min-w-full w-max *:flex *:flex-col", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
ref,
|
||||
title,
|
||||
allowCopy = true,
|
||||
keepBackground = false,
|
||||
icon,
|
||||
viewportProps = {},
|
||||
children,
|
||||
Actions = (props) => (
|
||||
<div {...props} className={cn("empty:hidden", props.className)} />
|
||||
),
|
||||
...props
|
||||
}: CodeBlockProps) {
|
||||
const inTab = useContext(TabsContext) !== null;
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<figure
|
||||
ref={ref}
|
||||
dir="ltr"
|
||||
{...props}
|
||||
className={cn(
|
||||
inTab
|
||||
? "bg-fd-secondary -mx-px -mb-px last:rounded-b-xl"
|
||||
: "my-4 bg-fd-card rounded-xl",
|
||||
keepBackground && "bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)",
|
||||
|
||||
"shiki relative border shadow-sm outline-none not-prose overflow-hidden text-sm",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<div className="flex text-fd-muted-foreground items-center gap-2 h-9.5 border-b px-4">
|
||||
{typeof icon === "string" ? (
|
||||
<div
|
||||
className="[&_svg]:size-3.5"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: icon,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<figcaption className="flex-1 truncate">{title}</figcaption>
|
||||
{Actions({
|
||||
className: "-me-2",
|
||||
children: allowCopy && <CopyButton containerRef={areaRef} />,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
Actions({
|
||||
className:
|
||||
"absolute top-2 right-2 z-2 backdrop-blur-lg rounded-lg text-fd-muted-foreground",
|
||||
children: allowCopy && <CopyButton containerRef={areaRef} />,
|
||||
})
|
||||
)}
|
||||
<div
|
||||
ref={areaRef}
|
||||
{...viewportProps}
|
||||
className={cn(
|
||||
"text-[13px] py-3.5 overflow-auto max-h-[600px] fd-scroll-container",
|
||||
viewportProps.className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
// space for toolbar
|
||||
"--padding-right": !title ? "calc(var(--spacing) * 8)" : undefined,
|
||||
counterSet: props["data-line-numbers"]
|
||||
? `line ${Number(props["data-line-numbers-start"] ?? 1) - 1}`
|
||||
: undefined,
|
||||
...viewportProps.style,
|
||||
} as object
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
className,
|
||||
containerRef,
|
||||
...props
|
||||
}: ComponentProps<"button"> & {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const [checked, onClick] = useCopyButton(() => {
|
||||
const pre = containerRef.current?.getElementsByTagName("pre").item(0);
|
||||
if (!pre) return;
|
||||
|
||||
const clone = pre.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll(".nd-copy-ignore").forEach((node) => {
|
||||
node.replaceWith("\n");
|
||||
});
|
||||
|
||||
void navigator.clipboard.writeText(clone.textContent ?? "");
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-checked={checked || undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
className:
|
||||
"hover:text-fd-accent-foreground data-checked:text-fd-accent-foreground",
|
||||
size: "icon-xs",
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
aria-label={checked ? "Copied Text" : "Copy Text"}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{checked ? <Check /> : <Clipboard />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlockTabs({ ref, ...props }: ComponentProps<typeof Tabs>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const nested = useContext(TabsContext) !== null;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
ref={mergeRefs(containerRef, ref)}
|
||||
{...props}
|
||||
className={cn(
|
||||
"bg-fd-card rounded-xl border",
|
||||
!nested && "my-4",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<TabsContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
containerRef,
|
||||
nested,
|
||||
}),
|
||||
[nested],
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</TabsContext.Provider>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlockTabsList(props: ComponentProps<typeof TabsList>) {
|
||||
return (
|
||||
<TabsList
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex flex-row px-2 overflow-x-auto text-fd-muted-foreground",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</TabsList>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlockTabsTrigger({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof TabsTrigger>) {
|
||||
return (
|
||||
<TabsTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative group inline-flex text-sm font-medium text-nowrap items-center transition-colors gap-2 px-2 py-1.5 hover:text-fd-accent-foreground data-[state=active]:text-fd-primary [&_svg]:size-3.5",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-x-2 bottom-0 h-px group-data-[state=active]:bg-fd-primary" />
|
||||
{children}
|
||||
</TabsTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: currently Vite RSC plugin has problem with `asChild` due to children is automatically wrapped in <Fragment />, maybe revisit this in future
|
||||
export function CodeBlockTab(props: ComponentProps<typeof TabsContent>) {
|
||||
return <TabsContent {...props} />;
|
||||
}
|
||||
163
docs/components/tabs.unstyled.tsx
Normal file
163
docs/components/tabs.unstyled.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import * as Primitive from "@radix-ui/react-tabs";
|
||||
import { useEffectEvent } from "fumadocs-core/utils/use-effect-event";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { mergeRefs } from "../lib/merge-refs";
|
||||
|
||||
type ChangeListener = (v: string) => void;
|
||||
const listeners = new Map<string, ChangeListener[]>();
|
||||
|
||||
function addChangeListener(id: string, listener: ChangeListener): void {
|
||||
const list = listeners.get(id) ?? [];
|
||||
list.push(listener);
|
||||
listeners.set(id, list);
|
||||
}
|
||||
|
||||
function removeChangeListener(id: string, listener: ChangeListener): void {
|
||||
const list = listeners.get(id) ?? [];
|
||||
listeners.set(
|
||||
id,
|
||||
list.filter((item) => item !== listener),
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsProps extends ComponentProps<typeof Primitive.Tabs> {
|
||||
/**
|
||||
* Identifier for Sharing value of tabs
|
||||
*/
|
||||
groupId?: string;
|
||||
|
||||
/**
|
||||
* Enable persistent
|
||||
*/
|
||||
persist?: boolean;
|
||||
|
||||
/**
|
||||
* If true, updates the URL hash based on the tab's id
|
||||
*/
|
||||
updateAnchor?: boolean;
|
||||
}
|
||||
|
||||
const TabsContext = createContext<{
|
||||
valueToIdMap: Map<string, string>;
|
||||
} | null>(null);
|
||||
|
||||
function useTabContext() {
|
||||
const ctx = useContext(TabsContext);
|
||||
if (!ctx) throw new Error("You must wrap your component in <Tabs>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const TabsList = Primitive.TabsList;
|
||||
|
||||
export const TabsTrigger = Primitive.TabsTrigger;
|
||||
|
||||
/**
|
||||
* @internal You better not use it
|
||||
*/
|
||||
export function Tabs({
|
||||
ref,
|
||||
groupId,
|
||||
persist = false,
|
||||
updateAnchor = false,
|
||||
defaultValue,
|
||||
value: _value,
|
||||
onValueChange: _onValueChange,
|
||||
...props
|
||||
}: TabsProps) {
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const [value, setValue] =
|
||||
_value === undefined
|
||||
? // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled
|
||||
useState(defaultValue)
|
||||
: [_value, _onValueChange ?? (() => undefined)];
|
||||
|
||||
const onChange = useEffectEvent((v: string) => setValue(v));
|
||||
const valueToIdMap = useMemo(() => new Map<string, string>(), []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!groupId) return;
|
||||
const previous = persist
|
||||
? localStorage.getItem(groupId)
|
||||
: sessionStorage.getItem(groupId);
|
||||
|
||||
if (previous) onChange(previous);
|
||||
addChangeListener(groupId, onChange);
|
||||
return () => {
|
||||
removeChangeListener(groupId, onChange);
|
||||
};
|
||||
}, [groupId, persist]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
for (const [value, id] of valueToIdMap.entries()) {
|
||||
if (id === hash) {
|
||||
onChange(value);
|
||||
tabsRef.current?.scrollIntoView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [valueToIdMap]);
|
||||
|
||||
return (
|
||||
<Primitive.Tabs
|
||||
ref={mergeRefs(ref, tabsRef)}
|
||||
value={value}
|
||||
onValueChange={(v: string) => {
|
||||
if (updateAnchor) {
|
||||
const id = valueToIdMap.get(v);
|
||||
|
||||
if (id) {
|
||||
window.history.replaceState(null, "", `#${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
listeners.get(groupId)?.forEach((item) => {
|
||||
item(v);
|
||||
});
|
||||
|
||||
if (persist) localStorage.setItem(groupId, v);
|
||||
else sessionStorage.setItem(groupId, v);
|
||||
} else {
|
||||
setValue(v);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<TabsContext.Provider
|
||||
value={useMemo(() => ({ valueToIdMap }), [valueToIdMap])}
|
||||
>
|
||||
{props.children}
|
||||
</TabsContext.Provider>
|
||||
</Primitive.Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({
|
||||
value,
|
||||
...props
|
||||
}: ComponentProps<typeof Primitive.TabsContent>) {
|
||||
const { valueToIdMap } = useTabContext();
|
||||
|
||||
if (props.id) {
|
||||
valueToIdMap.set(value, props.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Primitive.TabsContent value={value} {...props}>
|
||||
{props.children}
|
||||
</Primitive.TabsContent>
|
||||
);
|
||||
}
|
||||
62
docs/components/ui/button.tsx
Normal file
62
docs/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
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:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input 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",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
60
docs/components/ui/rainbow-button.tsx
Normal file
60
docs/components/ui/rainbow-button.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const rainbowButtonVariants = cva(
|
||||
cn(
|
||||
"relative cursor-pointer group transition-all animate-rainbow",
|
||||
"inline-flex items-center justify-center gap-2 shrink-0",
|
||||
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
|
||||
"text-sm font-medium whitespace-nowrap",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
|
||||
outline:
|
||||
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-xl px-3 text-xs",
|
||||
lg: "h-11 rounded-xl px-8",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface RainbowButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof rainbowButtonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(rainbowButtonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RainbowButton.displayName = "RainbowButton"
|
||||
|
||||
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
|
||||
Loading…
Add table
Add a link
Reference in a new issue