refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,18 @@
import styles from './styles.module.scss';
const BackgroundRays = () => {
return (
<div className={`${styles.rayContainer} `}>
<div className={`${styles.lightRay} ${styles.ray1}`}></div>
<div className={`${styles.lightRay} ${styles.ray2}`}></div>
<div className={`${styles.lightRay} ${styles.ray3}`}></div>
<div className={`${styles.lightRay} ${styles.ray4}`}></div>
<div className={`${styles.lightRay} ${styles.ray5}`}></div>
<div className={`${styles.lightRay} ${styles.ray6}`}></div>
<div className={`${styles.lightRay} ${styles.ray7}`}></div>
<div className={`${styles.lightRay} ${styles.ray8}`}></div>
</div>
);
};
export default BackgroundRays;

View File

@@ -0,0 +1,246 @@
.rayContainer {
// Theme-specific colors
--ray-color-primary: color-mix(in srgb, var(--primary-color), transparent 30%);
--ray-color-secondary: color-mix(in srgb, var(--secondary-color), transparent 30%);
--ray-color-accent: color-mix(in srgb, var(--accent-color), transparent 30%);
// Theme-specific gradients
--ray-gradient-primary: radial-gradient(var(--ray-color-primary) 0%, transparent 70%);
--ray-gradient-secondary: radial-gradient(var(--ray-color-secondary) 0%, transparent 70%);
--ray-gradient-accent: radial-gradient(var(--ray-color-accent) 0%, transparent 70%);
position: fixed;
inset: 0;
overflow: hidden;
animation: fadeIn 1.5s ease-out;
pointer-events: none;
z-index: 0;
// background-color: transparent;
:global(html[data-theme='dark']) & {
mix-blend-mode: screen;
}
:global(html[data-theme='light']) & {
mix-blend-mode: multiply;
}
}
.lightRay {
position: absolute;
border-radius: 100%;
:global(html[data-theme='dark']) & {
mix-blend-mode: screen;
}
:global(html[data-theme='light']) & {
mix-blend-mode: multiply;
opacity: 0.4;
}
}
.ray1 {
width: 600px;
height: 800px;
background: var(--ray-gradient-primary);
transform: rotate(65deg);
top: -500px;
left: -100px;
filter: blur(80px);
opacity: 0.6;
animation: float1 15s infinite ease-in-out;
}
.ray2 {
width: 400px;
height: 600px;
background: var(--ray-gradient-secondary);
transform: rotate(-30deg);
top: -300px;
left: 200px;
filter: blur(60px);
opacity: 0.6;
animation: float2 18s infinite ease-in-out;
}
.ray3 {
width: 500px;
height: 400px;
background: var(--ray-gradient-accent);
top: -320px;
left: 500px;
filter: blur(65px);
opacity: 0.5;
animation: float3 20s infinite ease-in-out;
}
.ray4 {
width: 400px;
height: 450px;
background: var(--ray-gradient-secondary);
top: -350px;
left: 800px;
filter: blur(55px);
opacity: 0.55;
animation: float4 17s infinite ease-in-out;
}
.ray5 {
width: 350px;
height: 500px;
background: var(--ray-gradient-primary);
transform: rotate(-45deg);
top: -250px;
left: 1000px;
filter: blur(45px);
opacity: 0.6;
animation: float5 16s infinite ease-in-out;
}
.ray6 {
width: 300px;
height: 700px;
background: var(--ray-gradient-accent);
transform: rotate(75deg);
top: -400px;
left: 600px;
filter: blur(75px);
opacity: 0.45;
animation: float6 19s infinite ease-in-out;
}
.ray7 {
width: 450px;
height: 600px;
background: var(--ray-gradient-primary);
transform: rotate(45deg);
top: -450px;
left: 350px;
filter: blur(65px);
opacity: 0.55;
animation: float7 21s infinite ease-in-out;
}
.ray8 {
width: 380px;
height: 550px;
background: var(--ray-gradient-secondary);
transform: rotate(-60deg);
top: -380px;
left: 750px;
filter: blur(58px);
opacity: 0.6;
animation: float8 14s infinite ease-in-out;
}
@keyframes float1 {
0%,
100% {
transform: rotate(65deg) translate(0, 0);
}
25% {
transform: rotate(70deg) translate(30px, 20px);
}
50% {
transform: rotate(60deg) translate(-20px, 40px);
}
75% {
transform: rotate(68deg) translate(-40px, 10px);
}
}
@keyframes float2 {
0%,
100% {
transform: rotate(-30deg) scale(1);
}
33% {
transform: rotate(-25deg) scale(1.1);
}
66% {
transform: rotate(-35deg) scale(0.95);
}
}
@keyframes float3 {
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
25% {
transform: translate(40px, 20px) rotate(5deg);
}
75% {
transform: translate(-30px, 40px) rotate(-5deg);
}
}
@keyframes float4 {
0%,
100% {
transform: scale(1) rotate(0deg);
}
50% {
transform: scale(1.15) rotate(10deg);
}
}
@keyframes float5 {
0%,
100% {
transform: rotate(-45deg) translate(0, 0);
}
33% {
transform: rotate(-40deg) translate(25px, -20px);
}
66% {
transform: rotate(-50deg) translate(-25px, 20px);
}
}
@keyframes float6 {
0%,
100% {
transform: rotate(75deg) scale(1);
filter: blur(75px);
}
50% {
transform: rotate(85deg) scale(1.1);
filter: blur(65px);
}
}
@keyframes float7 {
0%,
100% {
transform: rotate(45deg) translate(0, 0);
opacity: 0.55;
}
50% {
transform: rotate(40deg) translate(-30px, 30px);
opacity: 0.65;
}
}
@keyframes float8 {
0%,
100% {
transform: rotate(-60deg) scale(1);
}
25% {
transform: rotate(-55deg) scale(1.05);
}
75% {
transform: rotate(-65deg) scale(0.95);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,32 @@
'use client';
import { cva, type VariantProps } from 'class-variance-authority';
import classNames from 'classnames';
import * as React from 'react';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-upage-elements-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-upage-elements-background text-upage-elements-textPrimary hover:bg-upage-elements-background/80',
secondary:
'border-transparent bg-upage-elements-background text-upage-elements-textSecondary hover:bg-upage-elements-background/80',
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
outline: 'text-upage-elements-textPrimary',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={classNames(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,47 @@
import { cva, type VariantProps } from 'class-variance-authority';
import classNames from 'classnames';
import * as React from 'react';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-upage-elements-borderColor disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-upage-elements-background text-upage-elements-textPrimary hover:bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 dark:text-upage-elements-textPrimary dark:hover:bg-upage-elements-background-depth-3',
destructive: 'bg-red-500 text-white hover:bg-red-600',
outline:
'border border-upage-elements-borderColor bg-transparent hover:bg-upage-elements-background-depth-2 hover:text-upage-elements-textPrimary text-upage-elements-textPrimary dark:border-upage-elements-borderColorActive',
secondary:
'bg-upage-elements-background-depth-1 text-upage-elements-textPrimary hover:bg-upage-elements-background-depth-2',
ghost: 'hover:bg-upage-elements-background-depth-1 hover:text-upage-elements-textPrimary',
link: 'text-upage-elements-textPrimary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
_asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, _asChild = false, ...props }, ref) => {
return <button className={classNames(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
const Card = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={classNames(
'rounded-lg border border-upage-elements-borderColor bg-upage-elements-background-depth-1 text-upage-elements-textPrimary shadow-sm',
className,
)}
{...props}
/>
);
});
Card.displayName = 'Card';
const CardHeader = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return <div ref={ref} className={classNames('flex flex-col space-y-1.5 p-6', className)} {...props} />;
});
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => {
return (
<h3
ref={ref}
className={classNames('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
},
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
return <p ref={ref} className={classNames('text-sm text-upage-elements-textSecondary', className)} {...props} />;
},
);
CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => {
return <div ref={ref} className={classNames('p-6 pt-0', className)} {...props} />;
});
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={classNames('flex items-center p-6 pt-0', className)} {...props} />
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,31 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import classNames from 'classnames';
import * as React from 'react';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={classNames(
'peer size-4 shrink-0 rounded-sm border transition-colors',
'bg-transparent dark:bg-transparent',
'border-gray-400 dark:border-gray-600',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-purple-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-purple-500 dark:data-[state=checked]:bg-purple-500',
'data-[state=checked]:border-purple-500 dark:data-[state=checked]:border-purple-500',
'data-[state=checked]:text-white',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<div className="i-lucide:check size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = 'Checkbox';
export { Checkbox };

View File

@@ -0,0 +1,9 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
const CollapsibleContent = CollapsiblePrimitive.Content;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,445 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import classNames from 'classnames';
import { motion, type Variants } from 'framer-motion';
import React, { memo, type ReactNode, useEffect, useMemo, useState } from 'react';
import { List, type RowComponentProps } from 'react-window';
import { cubicEasingFn } from '~/.client/utils/easings';
import { Button } from './Button';
import { Checkbox } from './Checkbox';
import { IconButton } from './IconButton';
import { Label } from './Label';
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
interface DialogButtonProps {
type: 'primary' | 'secondary' | 'danger';
children: ReactNode;
onClick?: (event: React.MouseEvent) => void;
disabled?: boolean;
}
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
return (
<button
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors',
type === 'primary'
? 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-500 dark:hover:bg-purple-600'
: type === 'secondary'
? 'bg-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
: 'bg-transparent text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10',
)}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
});
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
return (
<RadixDialog.Title
className={classNames('text-lg font-medium text-upage-elements-textPrimary flex items-center gap-2', className)}
{...props}
>
{children}
</RadixDialog.Title>
);
});
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return (
<RadixDialog.Description
className={classNames('text-sm text-upage-elements-textSecondary mt-1', className)}
{...props}
>
{children}
</RadixDialog.Description>
);
});
const transition = {
duration: 0.15,
ease: cubicEasingFn,
};
export const dialogBackdropVariants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
} satisfies Variants;
export const dialogVariants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
} satisfies Variants;
interface DialogProps {
children: ReactNode;
className?: string;
showCloseButton?: boolean;
onClose?: () => void;
onBackdrop?: () => void;
}
export const Dialog = memo(({ children, className, showCloseButton = true, onClose, onBackdrop }: DialogProps) => {
return (
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
initial="closed"
animate="open"
exit="closed"
variants={dialogBackdropVariants}
onClick={onBackdrop}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-1/2 overflow-hidden left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[520px]',
className,
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<div className="flex flex-col">
{children}
{showCloseButton && (
<RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="absolute top-3 right-3 text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
/>
</RadixDialog.Close>
)}
</div>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
);
});
/**
* Props for the ConfirmationDialog component
*/
export interface ConfirmationDialogProps {
/**
* Whether the dialog is open
*/
isOpen: boolean;
/**
* Callback when the dialog is closed
*/
onClose: () => void;
/**
* Callback when the confirm button is clicked
*/
onConfirm: () => void;
/**
* The title of the dialog
*/
title: string;
/**
* The description of the dialog
*/
description: string;
/**
* The text for the confirm button
*/
confirmLabel?: string;
/**
* The text for the cancel button
*/
cancelLabel?: string;
/**
* The variant of the confirm button
*/
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
/**
* Whether the confirm button is in a loading state
*/
isLoading?: boolean;
}
/**
* A reusable confirmation dialog component that uses the Dialog component
*/
export function ConfirmationDialog({
isOpen,
onClose,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
isLoading = false,
onConfirm,
}: ConfirmationDialogProps) {
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog showCloseButton={false}>
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mb-4">{description}</DialogDescription>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{cancelLabel}
</Button>
<Button
variant={variant}
onClick={onConfirm}
disabled={isLoading}
className={
variant === 'destructive'
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent hover:bg-upage-elements-button-primary-backgroundHover'
}
>
{isLoading ? (
<>
<div className="i-ph-spinner-gap-bold animate-spin size-4 mr-2" />
{confirmLabel}
</>
) : (
confirmLabel
)}
</Button>
</div>
</div>
</Dialog>
</RadixDialog.Root>
);
}
/**
* Type for selection item in SelectionDialog
*/
type SelectionItem = {
id: string;
label: string;
description?: string;
};
/**
* Props for the SelectionDialog component
*/
export interface SelectionDialogProps {
/**
* The title of the dialog
*/
title: string;
/**
* The items to select from
*/
items: SelectionItem[];
/**
* Whether the dialog is open
*/
isOpen: boolean;
/**
* Callback when the dialog is closed
*/
onClose: () => void;
/**
* Callback when the confirm button is clicked with selected item IDs
*/
onConfirm: (selectedIds: string[]) => void;
/**
* The text for the confirm button
*/
confirmLabel?: string;
/**
* The maximum height of the selection list
*/
height?: number;
}
function RowComponent({
items,
index,
style,
selectedItems,
onToggleItem,
}: RowComponentProps<{
items: SelectionItem[];
selectedItems: string[];
onToggleItem: (id: string) => void;
}>) {
const item = useMemo(() => items[index], [items, index]);
return (
<div
className={classNames(
'flex items-start space-x-3 p-2 rounded-md transition-colors',
item.id
? 'bg-upage-elements-item-backgroundAccent'
: 'bg-upage-elements-bg-depth-2 hover:bg-upage-elements-item-backgroundActive',
)}
style={{
...style,
width: '100%',
boxSizing: 'border-box',
}}
>
<Checkbox
id={`item-${item.id}`}
checked={selectedItems.includes(item.id)}
onCheckedChange={() => onToggleItem(item.id)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor={`item-${item.id}`}
className={classNames(
'text-sm font-medium cursor-pointer',
selectedItems.includes(item.id)
? 'text-upage-elements-item-contentAccent'
: 'text-upage-elements-textPrimary',
)}
>
{item.label}
</Label>
{item.description && <p className="text-xs text-upage-elements-textSecondary">{item.description}</p>}
</div>
</div>
);
}
/**
* A reusable selection dialog component that uses the Dialog component
*/
export function SelectionDialog({
title,
items,
isOpen,
onClose,
onConfirm,
confirmLabel = 'Confirm',
height = 60,
}: SelectionDialogProps) {
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
// Reset selected items when dialog opens
useEffect(() => {
if (isOpen) {
setSelectedItems([]);
setSelectAll(false);
}
}, [isOpen]);
const handleToggleItem = (id: string) => {
setSelectedItems((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]));
};
const handleSelectAll = () => {
if (selectedItems.length === items.length) {
setSelectedItems([]);
setSelectAll(false);
} else {
setSelectedItems(items.map((item) => item.id));
setSelectAll(true);
}
};
const handleConfirm = () => {
onConfirm(selectedItems);
onClose();
};
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog showCloseButton={false}>
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-2 mb-4">
Select the items you want to include and click{' '}
<span className="text-upage-elements-item-contentAccent font-medium">{confirmLabel}</span>.
</DialogDescription>
<div className="py-4">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-upage-elements-textSecondary">
{selectedItems.length} of {items.length} selected
</span>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
className="text-xs h-8 px-2 text-upage-elements-textPrimary hover:text-upage-elements-item-contentAccent hover:bg-upage-elements-item-backgroundAccent bg-upage-elements-bg-depth-2 dark:bg-transparent"
>
{selectAll ? 'Deselect All' : 'Select All'}
</Button>
</div>
<div className="pr-2 border rounded-md border-upage-elements-borderColor bg-upage-elements-bg-depth-2">
{items.length > 0 ? (
<List
rowCount={items.length}
rowHeight={height}
rowComponent={RowComponent}
rowProps={{ items, selectedItems, onToggleItem: handleToggleItem }}
className="scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-upage-elements-bg-depth-3"
></List>
) : (
<div className="text-center py-4 text-sm text-upage-elements-textTertiary">No items to display</div>
)}
</div>
</div>
<div className="flex justify-between mt-6">
<Button
variant="outline"
onClick={onClose}
className="border-upage-elements-borderColor text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={selectedItems.length === 0}
className="bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50 disabled:pointer-events-none"
>
{confirmLabel}
</Button>
</div>
</div>
</Dialog>
</RadixDialog.Root>
);
}

View File

@@ -0,0 +1,63 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import classNames from 'classnames';
import { type ReactNode } from 'react';
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
align?: 'start' | 'center' | 'end';
sideOffset?: number;
}
interface DropdownItemProps {
children: ReactNode;
onSelect?: () => void;
className?: string;
}
export const DropdownItem = ({ children, onSelect, className }: DropdownItemProps) => (
<DropdownMenu.Item
className={classNames(
'relative flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
'text-upage-elements-textPrimary hover:text-upage-elements-textPrimary',
'hover:bg-upage-elements-background-depth-3',
'transition-colors cursor-pointer',
'outline-none',
className,
)}
onSelect={onSelect}
>
{children}
</DropdownMenu.Item>
);
export const DropdownSeparator = () => <DropdownMenu.Separator className="h-px bg-upage-elements-borderColor my-1" />;
export const Dropdown = ({ trigger, children, align = 'end', sideOffset = 5 }: DropdownProps) => {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={classNames(
'min-w-[220px] rounded-lg p-2',
'bg-upage-elements-background-depth-2',
'border border-upage-elements-borderColor',
'shadow-lg',
'animate-in fade-in-80 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',
'z-[1000]',
)}
sideOffset={sideOffset}
align={align}
>
{children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,84 @@
import classNames from 'classnames';
import { type ForwardedRef, forwardRef, memo } from 'react';
type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
interface BaseIconButtonProps {
size?: IconSize;
className?: string;
iconClassName?: string;
disabledClassName?: string;
title?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
type IconButtonWithoutChildrenProps = {
icon: string;
children?: undefined;
} & BaseIconButtonProps;
type IconButtonWithChildrenProps = {
icon?: undefined;
children: string | React.JSX.Element | React.JSX.Element[];
} & BaseIconButtonProps;
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
// Componente IconButton com suporte a refs
export const IconButton = memo(
forwardRef(
(
{
icon,
size = 'xl',
className,
iconClassName,
disabledClassName,
disabled = false,
title,
onClick,
children,
}: IconButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
) => {
return (
<button
ref={ref}
className={classNames(
'flex items-center text-upage-elements-item-contentDefault bg-transparent enabled:hover:text-upage-elements-item-contentActive rounded-md p-1 enabled:hover:bg-upage-elements-item-backgroundActive disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
title={title}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
</button>
);
},
),
);
function getIconSize(size: IconSize) {
if (size === 'sm') {
return 'text-sm';
} else if (size === 'md') {
return 'text-md';
} else if (size === 'lg') {
return 'text-lg';
} else if (size === 'xl') {
return 'text-xl';
} else {
return 'text-2xl';
}
}

View File

@@ -0,0 +1,22 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={classNames(
'flex h-10 w-full rounded-md border border-upage-elements-border bg-upage-elements-background px-3 py-2 text-sm ring-offset-upage-elements-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-upage-elements-textSecondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-upage-elements-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,20 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import classNames from 'classnames';
import * as React from 'react';
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={classNames(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,27 @@
import { memo, useEffect, useState } from 'react';
interface LoadingDotsProps {
text: string;
}
export const LoadingDots = memo(({ text }: LoadingDotsProps) => {
const [dotCount, setDotCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDotCount((prevDotCount) => (prevDotCount + 1) % 4);
}, 500);
return () => clearInterval(interval);
}, []);
return (
<div className="flex justify-center items-center h-full">
<div className="relative">
<span>{text}</span>
<span className="absolute left-[calc(100%-12px)]">{'.'.repeat(dotCount)}</span>
<span className="invisible">...</span>
</div>
</div>
);
});

View File

@@ -0,0 +1,32 @@
export const LoadingOverlay = ({
message = 'Loading...',
progress,
progressText,
}: {
message?: string;
progress?: number;
progressText?: string;
}) => {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50 backdrop-blur-sm">
<div className="relative flex flex-col items-center gap-4 p-8 rounded-lg bg-upage-elements-background-depth-2 shadow-lg">
<div
className={'i-svg-spinners:90-ring-with-bg text-upage-elements-loader-progress'}
style={{ fontSize: '2rem' }}
></div>
<p className="text-lg text-upage-elements-textTertiary">{message}</p>
{progress !== undefined && (
<div className="w-64 flex flex-col gap-2">
<div className="w-full h-2 bg-upage-elements-background-depth-1 rounded-full overflow-hidden">
<div
className="h-full bg-upage-elements-loader-progress transition-all duration-300 ease-out rounded-full"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
{progressText && <p className="text-sm text-upage-elements-textTertiary text-center">{progressText}</p>}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import classNames from 'classnames';
import { memo } from 'react';
interface PanelHeaderProps {
className?: string;
children: React.ReactNode;
}
export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
return (
<div
className={classNames(
'flex items-center gap-2 bg-upage-elements-background-depth-2 text-upage-elements-textSecondary border-b border-upage-elements-borderColor px-4 py-1 min-h-[34px] text-sm',
className,
)}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,36 @@
import classNames from 'classnames';
import { memo } from 'react';
interface PanelHeaderButtonProps {
className?: string;
disabledClassName?: string;
disabled?: boolean;
children: string | React.JSX.Element | Array<React.JSX.Element | string>;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const PanelHeaderButton = memo(
({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
return (
<button
className={classNames(
'flex items-center shrink-0 gap-1.5 px-1.5 rounded-md py-0.5 text-upage-elements-item-contentDefault bg-transparent enabled:hover:text-upage-elements-item-contentAccent enabled:hover:bg-upage-elements-item-backgroundActive disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children}
</button>
);
},
);

View File

@@ -0,0 +1,29 @@
import * as Popover from '@radix-ui/react-popover';
import type { PropsWithChildren, ReactNode } from 'react';
export default ({
children,
trigger,
side,
align,
}: PropsWithChildren<{
trigger: ReactNode;
side: 'top' | 'right' | 'bottom' | 'left' | undefined;
align: 'center' | 'start' | 'end' | undefined;
}>) => (
<Popover.Root>
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Popover.Anchor />
<Popover.Portal>
<Popover.Content
sideOffset={10}
side={side}
align={align}
className="bg-upage-elements-background-depth-2 text-upage-elements-item-contentAccent p-2 rounded-md shadow-xl z-workbench"
>
{children}
<Popover.Arrow className="bg-upage-elements-item-background-depth-2" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);

View File

@@ -0,0 +1,42 @@
import classNames from 'classnames';
import * as React from 'react';
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number;
color?: 'default' | 'purple' | 'red';
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value, color = 'default', ...props }, ref) => {
const getBarColor = () => {
switch (color) {
case 'purple':
return 'bg-purple-500 dark:bg-purple-400';
case 'red':
return 'bg-orange-500 dark:bg-orange-400';
default:
return 'bg-upage-elements-textPrimary';
}
};
return (
<div
ref={ref}
className={classNames(
'relative h-2 w-full overflow-hidden rounded-full bg-upage-elements-background',
className,
)}
{...props}
>
<div
className={classNames('size-full flex-1 transition-all', getBarColor())}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
);
},
);
Progress.displayName = 'Progress';
export { Progress };

View File

@@ -0,0 +1,41 @@
'use client';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import classNames from 'classnames';
import * as React from 'react';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={classNames('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="size-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={classNames(
'flex touch-none select-none transition-colors',
{
'h-full w-2.5 border-l border-l-transparent p-[1px]': orientation === 'vertical',
'h-2.5 flex-col border-t border-t-transparent p-[1px]': orientation === 'horizontal',
},
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-upage-elements-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,22 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import classNames from 'classnames';
interface SeparatorProps {
className?: string;
orientation?: 'horizontal' | 'vertical';
}
export const Separator = ({ className, orientation = 'horizontal' }: SeparatorProps) => {
return (
<SeparatorPrimitive.Root
className={classNames(
'bg-upage-elements-borderColor',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
orientation={orientation}
/>
);
};
export default Separator;

View File

@@ -0,0 +1,19 @@
import { memo } from 'react';
import { IconButton } from '~/.client/components/ui/IconButton';
interface SettingsButtonProps {
onClick: () => void;
}
export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
return (
<IconButton
onClick={onClick}
icon="i-ph:gear"
size="xl"
title="Settings"
data-testid="settings-button"
className="text-[#666] hover:text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive/10 transition-colors"
/>
);
});

View File

@@ -0,0 +1,82 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import { memo } from 'react';
import { cubicEasingFn } from '~/.client/utils/easings';
import { genericMemo } from '~/.client/utils/react';
export type SliderOptions<T> = {
left: { value: T; text: string };
middle?: { value: T; text: string };
right: { value: T; text: string };
};
interface SliderProps<T> {
selected: T;
options: SliderOptions<T>;
disabled?: boolean;
setSelected?: (selected: T) => void;
}
export const Slider = genericMemo(<T,>({ selected, options, setSelected, disabled = false }: SliderProps<T>) => {
const hasMiddle = !!options.middle;
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
return (
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-upage-elements-background-depth-1 overflow-hidden rounded-full p-1">
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)} disabled={disabled}>
{options.left.text}
</SliderButton>
{options.middle && (
<SliderButton
selected={isMiddleSelected}
setSelected={() => setSelected?.(options.middle!.value)}
disabled={disabled}
>
{options.middle.text}
</SliderButton>
)}
<SliderButton
selected={!isLeftSelected && !isMiddleSelected}
setSelected={() => setSelected?.(options.right.value)}
disabled={disabled}
>
{options.right.text}
</SliderButton>
</div>
);
});
interface SliderButtonProps {
selected: boolean;
children: string | React.JSX.Element | Array<React.JSX.Element | string>;
disabled?: boolean;
setSelected: () => void;
}
const SliderButton = memo(({ selected, children, setSelected, disabled = false }: SliderButtonProps) => {
return (
<button
onClick={disabled ? undefined : setSelected}
className={classNames(
'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
selected
? 'text-upage-elements-item-contentAccent'
: 'text-upage-elements-item-contentDefault hover:text-upage-elements-item-contentAccent',
disabled ? 'opacity-50 cursor-not-allowed hover:text-upage-elements-item-contentDefault' : '',
)}
disabled={disabled}
>
<span className="relative z-10">{children}</span>
{selected && (
<motion.span
layoutId="pill-tab"
transition={{ duration: 0.2, ease: cubicEasingFn }}
className="absolute inset-0 z-0 bg-upage-elements-item-backgroundAccent rounded-full"
></motion.span>
)}
</button>
);
});

View File

@@ -0,0 +1,37 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import classNames from 'classnames';
import { memo } from 'react';
interface SwitchProps {
className?: string;
checked?: boolean;
onCheckedChange?: (event: boolean) => void;
}
export const Switch = memo(({ className, onCheckedChange, checked }: SwitchProps) => {
return (
<SwitchPrimitive.Root
className={classNames(
'relative h-6 w-11 cursor-pointer rounded-full bg-upage-elements-button-primary-background',
'transition-colors duration-200 ease-in-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-upage-elements-item-contentAccent',
className,
)}
checked={checked}
onCheckedChange={(e) => onCheckedChange?.(e)}
>
<SwitchPrimitive.Thumb
className={classNames(
'block size-5 rounded-full bg-white',
'shadow-lg shadow-black/20',
'transition-transform duration-200 ease-in-out',
'translate-x-0.5',
'data-[state=checked]:translate-x-[1.375rem]',
'will-change-transform',
)}
/>
</SwitchPrimitive.Root>
);
});

View File

@@ -0,0 +1,52 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import classNames from 'classnames';
import * as React from 'react';
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={classNames(
'inline-flex h-10 items-center justify-center rounded-md bg-upage-elements-background p-1 text-upage-elements-textSecondary',
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={classNames(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-upage-elements-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-upage-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-upage-elements-background data-[state=active]:text-upage-elements-textPrimary data-[state=active]:shadow-sm',
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={classNames(
'mt-2 ring-offset-upage-elements-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-upage-elements-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,36 @@
import { useStore } from '@nanostores/react';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useEffect, useState } from 'react';
import { cubicEasingFn } from '~/.client/utils/easings';
import { themeStore, toggleTheme } from '~/stores/theme';
import { IconButton } from './IconButton';
interface ThemeSwitchProps {
className?: string;
}
export const ThemeSwitch = memo(({ className }: ThemeSwitchProps) => {
const theme = useStore(themeStore);
const [domLoaded, setDomLoaded] = useState(false);
useEffect(() => {
setDomLoaded(true);
}, []);
return (
domLoaded && (
<IconButton className={className} title="切换主题" onClick={toggleTheme}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={theme}
initial={{ rotateZ: -30, opacity: 0 }}
animate={{ rotateZ: 0, opacity: 1 }}
exit={{ rotateZ: 30, opacity: 0 }}
transition={{ duration: 0.3, ease: cubicEasingFn }}
className={theme === 'dark' ? 'i-mingcute:sun-line text-xl' : 'i-mingcute:moon-line text-xl'}
/>
</AnimatePresence>
</IconButton>
)
);
});

View File

@@ -0,0 +1,79 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { type ForwardedRef, forwardRef, type ReactElement } from 'react';
interface TooltipProps {
tooltip: React.ReactNode;
children: ReactElement;
sideOffset?: number;
className?: string;
arrowClassName?: string;
tooltipStyle?: React.CSSProperties;
position?: 'top' | 'bottom' | 'left' | 'right';
maxWidth?: number;
delay?: number;
}
const WithTooltip = forwardRef(
(
{
tooltip,
children,
sideOffset = 5,
className = '',
arrowClassName = '',
tooltipStyle = {},
position = 'top',
maxWidth = 250,
delay = 0,
}: TooltipProps,
_ref: ForwardedRef<HTMLElement>,
) => {
return (
<Tooltip.Root delayDuration={delay}>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={position}
className={`
z-[2000]
px-2.5
py-1.5
max-h-[300px]
select-none
rounded-md
bg-upage-elements-background-depth-3
text-upage-elements-textPrimary
text-sm
leading-tight
shadow-lg
animate-in
fade-in-0
zoom-in-95
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
data-[state=closed]:zoom-out-95
${className}
`}
sideOffset={sideOffset}
style={{
maxWidth,
...tooltipStyle,
}}
>
<div className="break-words">{tooltip}</div>
<Tooltip.Arrow
className={`
fill-upage-elements-background-depth-3
${arrowClassName}
`}
width={12}
height={6}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
},
);
export default WithTooltip;