continue re-design
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import CurvyRect from "@/components/shared/layout/curvy-rect";
|
||||
import { cn } from "@/utils/cn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogContent as ShadDialogContent,
|
||||
} from "@/components/ui/shadcn/dialog";
|
||||
|
||||
type AppDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof ShadDialogContent
|
||||
> & {
|
||||
withCurvyRect?: boolean;
|
||||
bodyClassName?: string;
|
||||
};
|
||||
|
||||
export function AppDialogContent({
|
||||
className,
|
||||
children,
|
||||
withCurvyRect = true,
|
||||
bodyClassName,
|
||||
...props
|
||||
}: AppDialogContentProps) {
|
||||
return (
|
||||
<ShadDialogContent
|
||||
className={cn(
|
||||
"sm:rounded-16 p-0 border border-border-faint bg-white relative overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withCurvyRect && (
|
||||
<CurvyRect className="absolute inset-0 pointer-events-none" allSides />
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.985, y: 24 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 24, mass: 0.9 }}
|
||||
className={cn("relative p-16 pb-12", bodyClassName)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</ShadDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AnimatedDotIcon } from "@/components/shared/animated-dot-icon";
|
||||
|
||||
interface AsciiDotLoaderProps {
|
||||
size?: number;
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
pattern?: Parameters<typeof AnimatedDotIcon>[0]["pattern"];
|
||||
}
|
||||
|
||||
// Thin wrapper to reuse the exact ASCII pixel effect used on the home hero
|
||||
export default function AsciiDotLoader({
|
||||
size = 20,
|
||||
animated = true,
|
||||
className,
|
||||
pattern = "logs",
|
||||
}: AsciiDotLoaderProps) {
|
||||
return (
|
||||
<AnimatedDotIcon
|
||||
size={size}
|
||||
active={animated}
|
||||
alwaysHeat
|
||||
className={className}
|
||||
pattern={pattern}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import React from "react";
|
||||
|
||||
interface DotGridLoaderProps {
|
||||
size?: number; // pixel size of each dot
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
animated?: boolean;
|
||||
intensityMap?: number[]; // per-dot base opacity 0..1, length cols*rows
|
||||
}
|
||||
|
||||
export function DotGridLoader({
|
||||
size = 10,
|
||||
cols = 3,
|
||||
rows = 3,
|
||||
className,
|
||||
animated = true,
|
||||
intensityMap,
|
||||
}: DotGridLoaderProps) {
|
||||
const total = cols * rows;
|
||||
const defaultMap = Array.from({ length: total }).map((_, i) => {
|
||||
// Row alphas tuned to hero prompt style: top=0.4, middle=1, bottom=0.12
|
||||
const row = Math.floor(i / cols);
|
||||
if (row === 0) return 0.4; // top
|
||||
if (row === 1) return 1.0; // middle
|
||||
return 0.12; // bottom
|
||||
});
|
||||
const bases =
|
||||
intensityMap && intensityMap.length === total ? intensityMap : defaultMap;
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, ${size}px)`,
|
||||
gap: Math.max(2, Math.round(size / 3)),
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: total }).map((_, i) => {
|
||||
const base = bases[i] ?? 0.8;
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: base }}
|
||||
animate={
|
||||
animated ? { opacity: [base, 1, base] } : { opacity: base }
|
||||
}
|
||||
transition={
|
||||
animated
|
||||
? { duration: 1.1, repeat: Infinity, delay: i * 0.08 }
|
||||
: undefined
|
||||
}
|
||||
style={{ width: size, height: size }}
|
||||
className="rounded-[2px] bg-heat-100"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DotGridLoader;
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { AsciiExplosion } from "@/components/shared/effects/flame";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
showFlame?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title = "No data yet",
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
showFlame = true,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center py-12 px-4 text-center",
|
||||
"min-h-[300px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Subtle flame background */}
|
||||
{showFlame && (
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<AsciiExplosion />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 space-y-4">
|
||||
{icon && (
|
||||
<div className="w-12 h-12 mx-auto text-black-alpha-40">{icon}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-label-large text-black-alpha-72">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-body-medium text-black-alpha-56 max-w-md mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && <div className="pt-2">{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// UI Components
|
||||
export { StatCard } from "./stat-card";
|
||||
export { LoadingState } from "./loading-state";
|
||||
export { EmptyState } from "./empty-state";
|
||||
export { default as CurvyRect } from "@/components/shared/layout/curvy-rect";
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { CoreFlame } from "@/components/shared/effects/flame";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
showFlame?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
message = "Loading...",
|
||||
showFlame = true,
|
||||
size = "md",
|
||||
className,
|
||||
}: LoadingStateProps) {
|
||||
const sizeClasses = {
|
||||
sm: "min-h-[200px]",
|
||||
md: "min-h-[300px]",
|
||||
lg: "min-h-[400px]",
|
||||
};
|
||||
|
||||
const spinnerSizes = {
|
||||
sm: "w-6 h-6",
|
||||
md: "w-8 h-8",
|
||||
lg: "w-10 h-10",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Subtle pulsing flame */}
|
||||
{showFlame && (
|
||||
<div className="absolute inset-0">
|
||||
<CoreFlame className="opacity-10 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 space-y-4">
|
||||
{/* Spinner */}
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto rounded-full border-2 border-black-alpha-20 border-t-heat-100 animate-spin",
|
||||
spinnerSizes[size],
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<p className="text-body-medium text-black-alpha-64">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { isMobileSheetOpenAtom } from "@/atoms/sheets";
|
||||
|
||||
interface MobileSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
showCloseButton?: boolean;
|
||||
position?: "top" | "bottom";
|
||||
spacing?: "sm" | "md" | "lg"; // sm=16px, md=20px, lg=24px from all edges
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
contentHeight?: "auto" | "fill" | "full"; // 'auto' for small content, 'fill' for lists, 'full' for nearly fullscreen
|
||||
contentPadding?: boolean; // Whether to add default padding to content area (default: true)
|
||||
closeOnOverlayClick?: boolean; // Whether clicking the overlay should close the sheet (default: true)
|
||||
}
|
||||
|
||||
// Hook to auto-close mobile sheets when transitioning to desktop
|
||||
function useAutoCloseOnDesktop(isOpen: boolean, onClose: () => void) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleResize = () => {
|
||||
// Close immediately if screen becomes larger than mobile breakpoint (768px for lg:)
|
||||
if (window.innerWidth >= 1024) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Check immediately in case we're already on desktop
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
}
|
||||
|
||||
export function MobileSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
showCloseButton = false,
|
||||
position = "bottom",
|
||||
spacing = "sm",
|
||||
children,
|
||||
className,
|
||||
contentHeight = "auto",
|
||||
contentPadding = true,
|
||||
closeOnOverlayClick = true,
|
||||
}: MobileSheetProps) {
|
||||
const [, setIsMobileSheetOpen] = useAtom(isMobileSheetOpenAtom);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopGradient, setShowTopGradient] = useState(false);
|
||||
const [showBottomGradient, setShowBottomGradient] = useState(false);
|
||||
|
||||
// Handle global mobile sheet state changes (always reflect actual open state)
|
||||
useEffect(() => {
|
||||
setIsMobileSheetOpen(isOpen);
|
||||
return () => setIsMobileSheetOpen(false);
|
||||
}, [isOpen, setIsMobileSheetOpen]);
|
||||
|
||||
// Lock background scroll when sheet is open, restore when closed
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
if (isOpen) {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-close when transitioning to desktop
|
||||
useAutoCloseOnDesktop(isOpen, onClose);
|
||||
|
||||
// Check initial scroll state when sheet opens
|
||||
useEffect(() => {
|
||||
if (isOpen && scrollRef.current) {
|
||||
// Small delay to ensure content is rendered
|
||||
setTimeout(() => {
|
||||
const target = scrollRef.current;
|
||||
if (target) {
|
||||
const isAtTop = target.scrollTop <= 5;
|
||||
const isAtBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight <= 5;
|
||||
const hasScroll = target.scrollHeight > target.clientHeight;
|
||||
|
||||
setShowTopGradient(hasScroll && !isAtTop);
|
||||
setShowBottomGradient(hasScroll && !isAtBottom);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Enhanced close handler
|
||||
const handleClose = () => {
|
||||
// Re-enable page scroll after closing sheet
|
||||
document.body.style.overflow = "";
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Define spacing values
|
||||
const getSpacingClass = () => {
|
||||
const horizontalSpacing =
|
||||
spacing === "sm"
|
||||
? "left-16 right-16"
|
||||
: spacing === "md"
|
||||
? "left-20 right-20"
|
||||
: "left-24 right-24";
|
||||
|
||||
if (contentHeight === "full") {
|
||||
if (spacing === "sm") return `${horizontalSpacing} top-16 bottom-16`;
|
||||
if (spacing === "md") return `${horizontalSpacing} top-20 bottom-20`;
|
||||
return `${horizontalSpacing} top-24 bottom-24`;
|
||||
}
|
||||
|
||||
const verticalSpacing =
|
||||
position === "top"
|
||||
? spacing === "sm"
|
||||
? "top-16"
|
||||
: spacing === "md"
|
||||
? "top-20"
|
||||
: "top-24"
|
||||
: spacing === "sm"
|
||||
? "bottom-16"
|
||||
: spacing === "md"
|
||||
? "bottom-20"
|
||||
: "bottom-24";
|
||||
|
||||
return `${horizontalSpacing} ${verticalSpacing}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] lg:hidden"
|
||||
onClick={() => {
|
||||
if (closeOnOverlayClick) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Sheet content - positioned from top or bottom */}
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: position === "top" ? -50 : 50,
|
||||
}}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: position === "top" ? -50 : 50,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 0.8,
|
||||
}}
|
||||
className={cn("absolute", getSpacingClass())}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background-base border border-border-faint rounded-12 shadow-2xl overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="px-24 py-16 border-b border-border-faint flex items-center justify-between">
|
||||
{title && (
|
||||
<h2 className="text-label-large text-accent-black">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={cn(
|
||||
"h-32 w-32 flex items-center justify-center transition-all rounded-6 border border-border-faint",
|
||||
"bg-black-alpha-4 hover:bg-black-alpha-6 active:scale-[0.98]",
|
||||
"text-black-alpha-64 hover:text-accent-black",
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-16 h-16" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content with gradient overlays */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
contentHeight === "full"
|
||||
? "h-full"
|
||||
: contentHeight === "fill"
|
||||
? "h-[65vh]"
|
||||
: "max-h-[60vh]",
|
||||
"overflow-y-auto scrollbar-hide",
|
||||
contentPadding && "p-16",
|
||||
)}
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const isAtTop = target.scrollTop <= 5;
|
||||
const isAtBottom =
|
||||
target.scrollHeight -
|
||||
target.scrollTop -
|
||||
target.clientHeight <=
|
||||
5;
|
||||
const hasScroll = target.scrollHeight > target.clientHeight;
|
||||
|
||||
// Update gradient visibility states
|
||||
setShowTopGradient(hasScroll && !isAtTop);
|
||||
setShowBottomGradient(hasScroll && !isAtBottom);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Animated fade gradients */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-white to-transparent pointer-events-none z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: showTopGradient ? 1 : 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-white to-transparent pointer-events-none z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: showBottomGradient ? 1 : 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
change?: number; // Percentage change
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
icon?: React.ReactNode;
|
||||
trend?: "up" | "down" | "neutral";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
suffix,
|
||||
prefix,
|
||||
icon,
|
||||
trend,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
const trendColor =
|
||||
trend === "up"
|
||||
? "text-green-600"
|
||||
: trend === "down"
|
||||
? "text-red-600"
|
||||
: "text-black-alpha-64";
|
||||
const trendBg =
|
||||
trend === "up"
|
||||
? "bg-green-50"
|
||||
: trend === "down"
|
||||
? "bg-red-50"
|
||||
: "bg-black-alpha-4";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-5 lg:p-6 border border-border-faint rounded-12 bg-white",
|
||||
"hover:border-black-alpha-16 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<p className="text-label-small text-black-alpha-64">{label}</p>
|
||||
{icon && <div className="w-5 h-5 text-black-alpha-40">{icon}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline gap-1">
|
||||
{prefix && (
|
||||
<span className="text-body-large text-black-alpha-64">
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-h3 font-semibold text-accent-black">
|
||||
{value}
|
||||
</span>
|
||||
{suffix && (
|
||||
<span className="text-body-large text-black-alpha-64">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(change !== undefined || trend) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{trend && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-6 flex items-center justify-center",
|
||||
trendBg,
|
||||
)}
|
||||
>
|
||||
{trend === "up" ? (
|
||||
<TrendingUp className={cn("w-3.5 h-3.5", trendColor)} />
|
||||
) : trend === "down" ? (
|
||||
<TrendingDown className={cn("w-3.5 h-3.5", trendColor)} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{change !== undefined && (
|
||||
<span className={cn("text-body-small ", trendColor)}>
|
||||
{change > 0 && "+"}
|
||||
{change}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user