🎉 first commit
This commit is contained in:
18
app/components/ui/BackgroundRays/index.tsx
Normal file
18
app/components/ui/BackgroundRays/index.tsx
Normal 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;
|
||||
246
app/components/ui/BackgroundRays/styles.module.scss
Normal file
246
app/components/ui/BackgroundRays/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
32
app/components/ui/Badge.tsx
Normal file
32
app/components/ui/Badge.tsx
Normal 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 };
|
||||
47
app/components/ui/Button.tsx
Normal file
47
app/components/ui/Button.tsx
Normal 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 };
|
||||
55
app/components/ui/Card.tsx
Normal file
55
app/components/ui/Card.tsx
Normal 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 };
|
||||
31
app/components/ui/Checkbox.tsx
Normal file
31
app/components/ui/Checkbox.tsx
Normal 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 };
|
||||
9
app/components/ui/Collapsible.tsx
Normal file
9
app/components/ui/Collapsible.tsx
Normal 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 };
|
||||
445
app/components/ui/Dialog.tsx
Normal file
445
app/components/ui/Dialog.tsx
Normal 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 '~/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>
|
||||
);
|
||||
}
|
||||
63
app/components/ui/Dropdown.tsx
Normal file
63
app/components/ui/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
84
app/components/ui/IconButton.tsx
Normal file
84
app/components/ui/IconButton.tsx
Normal 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';
|
||||
}
|
||||
}
|
||||
22
app/components/ui/Input.tsx
Normal file
22
app/components/ui/Input.tsx
Normal 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 };
|
||||
20
app/components/ui/Label.tsx
Normal file
20
app/components/ui/Label.tsx
Normal 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 };
|
||||
27
app/components/ui/LoadingDots.tsx
Normal file
27
app/components/ui/LoadingDots.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
32
app/components/ui/LoadingOverlay.tsx
Normal file
32
app/components/ui/LoadingOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
app/components/ui/PanelHeader.tsx
Normal file
20
app/components/ui/PanelHeader.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
36
app/components/ui/PanelHeaderButton.tsx
Normal file
36
app/components/ui/PanelHeaderButton.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
29
app/components/ui/Popover.tsx
Normal file
29
app/components/ui/Popover.tsx
Normal 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>
|
||||
);
|
||||
42
app/components/ui/Progress.tsx
Normal file
42
app/components/ui/Progress.tsx
Normal 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 };
|
||||
41
app/components/ui/ScrollArea.tsx
Normal file
41
app/components/ui/ScrollArea.tsx
Normal 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 };
|
||||
22
app/components/ui/Separator.tsx
Normal file
22
app/components/ui/Separator.tsx
Normal 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;
|
||||
19
app/components/ui/SettingsButton.tsx
Normal file
19
app/components/ui/SettingsButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { memo } from 'react';
|
||||
import { IconButton } from '~/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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
82
app/components/ui/Slider.tsx
Normal file
82
app/components/ui/Slider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { genericMemo } from '~/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>
|
||||
);
|
||||
});
|
||||
37
app/components/ui/Switch.tsx
Normal file
37
app/components/ui/Switch.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
52
app/components/ui/Tabs.tsx
Normal file
52
app/components/ui/Tabs.tsx
Normal 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 };
|
||||
36
app/components/ui/ThemeSwitch.tsx
Normal file
36
app/components/ui/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { themeStore, toggleTheme } from '~/lib/stores/theme';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
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>
|
||||
)
|
||||
);
|
||||
});
|
||||
79
app/components/ui/Tooltip.tsx
Normal file
79
app/components/ui/Tooltip.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user