initial commit
This commit is contained in:
41
apps/frontend/src/components/layout/Header.tsx
Normal file
41
apps/frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Menu } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSseStatus } from "@/lib/sse-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick }: HeaderProps) {
|
||||
const sseStatus = useSseStatus();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={onMenuClick}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
sseStatus === "connected" && "bg-emerald-500",
|
||||
sseStatus === "connecting" && "bg-amber-500 animate-pulse",
|
||||
sseStatus === "disconnected" && "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sseStatus === "connected" && "Live"}
|
||||
{sseStatus === "connecting" && "Conectando..."}
|
||||
{sseStatus === "disconnected" && "Desconectado"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
20
apps/frontend/src/components/layout/Layout.tsx
Normal file
20
apps/frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||
<main className="flex-1 p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/frontend/src/components/layout/Sidebar.tsx
Normal file
54
apps/frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { FileText, LayoutDashboard, TrendingUp, Wallet } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Panel", icon: LayoutDashboard },
|
||||
{ to: "/expenses", label: "Gastos", icon: Wallet },
|
||||
{ to: "/quotes", label: "Cotizaciones", icon: FileText },
|
||||
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-60 flex-col border-r bg-sidebar transition-transform duration-200 md:relative md:translate-x-0",
|
||||
open ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<span className="text-lg font-semibold text-sidebar-foreground">
|
||||
Personal Admin
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
apps/frontend/src/components/ui/button.tsx
Normal file
67
apps/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
512
apps/frontend/src/components/ui/gauge.tsx
Normal file
512
apps/frontend/src/components/ui/gauge.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
import { Slot as SlotPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GAUGE_NAME = "Gauge";
|
||||
const INDICATOR_NAME = "GaugeIndicator";
|
||||
const TRACK_NAME = "GaugeTrack";
|
||||
const RANGE_NAME = "GaugeRange";
|
||||
const VALUE_TEXT_NAME = "GaugeValueText";
|
||||
const LABEL_NAME = "GaugeLabel";
|
||||
|
||||
const DEFAULT_MAX = 100;
|
||||
const DEFAULT_START_ANGLE = 0;
|
||||
const DEFAULT_END_ANGLE = 360;
|
||||
|
||||
type GaugeState = "indeterminate" | "complete" | "loading";
|
||||
|
||||
interface DivProps extends React.ComponentProps<"div"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
interface PathProps extends React.ComponentProps<"path"> {}
|
||||
|
||||
function getGaugeState(
|
||||
value: number | undefined | null,
|
||||
maxValue: number,
|
||||
): GaugeState {
|
||||
return value == null
|
||||
? "indeterminate"
|
||||
: value === maxValue
|
||||
? "complete"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
function getIsValidNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function getIsValidMaxNumber(max: unknown): max is number {
|
||||
return getIsValidNumber(max) && max > 0;
|
||||
}
|
||||
|
||||
function getIsValidValueNumber(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
): value is number {
|
||||
return getIsValidNumber(value) && value <= max && value >= min;
|
||||
}
|
||||
|
||||
function getDefaultValueText(value: number, min: number, max: number): string {
|
||||
const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100;
|
||||
return Math.round(percentage).toString();
|
||||
}
|
||||
|
||||
function getInvalidValueError(
|
||||
propValue: string,
|
||||
componentName: string,
|
||||
): string {
|
||||
return `Invalid prop \`value\` of value \`${propValue}\` supplied to \`${componentName}\`. The \`value\` prop must be a number between \`min\` and \`max\` (inclusive), or \`null\`/\`undefined\` for indeterminate state. The value will be clamped to the valid range.`;
|
||||
}
|
||||
|
||||
function getInvalidMaxError(propValue: string, componentName: string): string {
|
||||
return `Invalid prop \`max\` of value \`${propValue}\` supplied to \`${componentName}\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`;
|
||||
}
|
||||
|
||||
function getNormalizedAngle(angle: number) {
|
||||
return ((angle % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function polarToCartesian(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
angleInDegrees: number,
|
||||
) {
|
||||
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
|
||||
return {
|
||||
x: centerX + radius * Math.cos(angleInRadians),
|
||||
y: centerY + radius * Math.sin(angleInRadians),
|
||||
};
|
||||
}
|
||||
|
||||
function describeArc(
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
) {
|
||||
const angleDiff = endAngle - startAngle;
|
||||
|
||||
// For full circles (360 degrees), draw as two semi-circles
|
||||
if (Math.abs(angleDiff) >= 360) {
|
||||
const start = polarToCartesian(x, y, radius, startAngle);
|
||||
const mid = polarToCartesian(x, y, radius, startAngle + 180);
|
||||
return [
|
||||
"M",
|
||||
start.x,
|
||||
start.y,
|
||||
"A",
|
||||
radius,
|
||||
radius,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
mid.x,
|
||||
mid.y,
|
||||
"A",
|
||||
radius,
|
||||
radius,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
start.x,
|
||||
start.y,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
const start = polarToCartesian(x, y, radius, startAngle);
|
||||
const end = polarToCartesian(x, y, radius, endAngle);
|
||||
const largeArcFlag = angleDiff <= 180 ? "0" : "1";
|
||||
|
||||
return [
|
||||
"M",
|
||||
start.x,
|
||||
start.y,
|
||||
"A",
|
||||
radius,
|
||||
radius,
|
||||
0,
|
||||
largeArcFlag,
|
||||
1,
|
||||
end.x,
|
||||
end.y,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
interface GaugeContextValue {
|
||||
value: number | null;
|
||||
valueText: string | undefined;
|
||||
max: number;
|
||||
min: number;
|
||||
state: GaugeState;
|
||||
radius: number;
|
||||
thickness: number;
|
||||
size: number;
|
||||
center: number;
|
||||
percentage: number | null;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
arcLength: number;
|
||||
arcCenterY: number;
|
||||
valueTextId?: string;
|
||||
labelId?: string;
|
||||
}
|
||||
|
||||
const GaugeContext = React.createContext<GaugeContextValue | null>(null);
|
||||
|
||||
function useGaugeContext(consumerName: string) {
|
||||
const context = React.useContext(GaugeContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`\`${consumerName}\` must be used within \`${GAUGE_NAME}\``,
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface GaugeProps extends DivProps {
|
||||
value?: number | null | undefined;
|
||||
getValueText?(value: number, min: number, max: number): string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
}
|
||||
|
||||
function Gauge(props: GaugeProps) {
|
||||
const {
|
||||
value: valueProp = null,
|
||||
getValueText = getDefaultValueText,
|
||||
min: minProp = 0,
|
||||
max: maxProp,
|
||||
size = 120,
|
||||
thickness = 8,
|
||||
startAngle = DEFAULT_START_ANGLE,
|
||||
endAngle = DEFAULT_END_ANGLE,
|
||||
asChild,
|
||||
className,
|
||||
...rootProps
|
||||
} = props;
|
||||
|
||||
if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error(getInvalidMaxError(`${maxProp}`, GAUGE_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX;
|
||||
const min = getIsValidNumber(minProp) ? minProp : 0;
|
||||
const max = rawMax <= min ? min + 1 : rawMax;
|
||||
|
||||
if (process.env.NODE_ENV !== "production" && thickness >= size) {
|
||||
console.warn(
|
||||
`Gauge: thickness (${thickness}) should be less than size (${size}) for proper rendering.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error(getInvalidValueError(`${valueProp}`, GAUGE_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
const value = getIsValidValueNumber(valueProp, min, max)
|
||||
? valueProp
|
||||
: getIsValidNumber(valueProp) && valueProp > max
|
||||
? max
|
||||
: getIsValidNumber(valueProp) && valueProp < min
|
||||
? min
|
||||
: null;
|
||||
|
||||
const valueText = getIsValidNumber(value)
|
||||
? getValueText(value, min, max)
|
||||
: undefined;
|
||||
const state = getGaugeState(value, max);
|
||||
const radius = Math.max(0, (size - thickness) / 2);
|
||||
const center = size / 2;
|
||||
|
||||
const angleDiff = Math.abs(endAngle - startAngle);
|
||||
const arcLength = (Math.min(angleDiff, 360) / 360) * (2 * Math.PI * radius);
|
||||
|
||||
const percentage = getIsValidNumber(value)
|
||||
? max === min
|
||||
? 1
|
||||
: (value - min) / (max - min)
|
||||
: null;
|
||||
|
||||
// Calculate the visual center Y of the arc for text positioning
|
||||
// For full circles, use geometric center. For partial arcs, calculate based on bounding box
|
||||
const angleDiffDeg = Math.abs(endAngle - startAngle);
|
||||
const isFullCircle = angleDiffDeg >= 360;
|
||||
|
||||
let arcCenterY = center;
|
||||
if (!isFullCircle) {
|
||||
const startRad = (startAngle * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
|
||||
const startY = center - radius * Math.cos(startRad);
|
||||
const endY = center - radius * Math.cos(endRad);
|
||||
|
||||
let minY = Math.min(startY, endY);
|
||||
let maxY = Math.max(startY, endY);
|
||||
|
||||
const normStart = getNormalizedAngle(startAngle);
|
||||
const normEnd = getNormalizedAngle(endAngle);
|
||||
|
||||
const includesTop =
|
||||
normStart > normEnd
|
||||
? normStart <= 270 || normEnd >= 270
|
||||
: normStart <= 270 && normEnd >= 270;
|
||||
const includesBottom =
|
||||
normStart > normEnd
|
||||
? normStart <= 90 || normEnd >= 90
|
||||
: normStart <= 90 && normEnd >= 90;
|
||||
|
||||
if (includesTop) minY = Math.min(minY, center - radius);
|
||||
if (includesBottom) maxY = Math.max(maxY, center + radius);
|
||||
|
||||
arcCenterY = (minY + maxY) / 2;
|
||||
}
|
||||
|
||||
const labelId = React.useId();
|
||||
const valueTextId = React.useId();
|
||||
|
||||
const contextValue = React.useMemo<GaugeContextValue>(
|
||||
() => ({
|
||||
value,
|
||||
valueText,
|
||||
max,
|
||||
min,
|
||||
state,
|
||||
radius,
|
||||
thickness,
|
||||
size,
|
||||
center,
|
||||
percentage,
|
||||
startAngle,
|
||||
endAngle,
|
||||
arcLength,
|
||||
arcCenterY,
|
||||
valueTextId,
|
||||
labelId,
|
||||
}),
|
||||
[
|
||||
value,
|
||||
valueText,
|
||||
max,
|
||||
min,
|
||||
state,
|
||||
radius,
|
||||
thickness,
|
||||
size,
|
||||
center,
|
||||
percentage,
|
||||
startAngle,
|
||||
endAngle,
|
||||
arcLength,
|
||||
arcCenterY,
|
||||
valueTextId,
|
||||
labelId,
|
||||
],
|
||||
);
|
||||
|
||||
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
|
||||
|
||||
return (
|
||||
<GaugeContext.Provider value={contextValue}>
|
||||
<RootPrimitive
|
||||
role="meter"
|
||||
aria-describedby={valueText ? valueTextId : undefined}
|
||||
aria-labelledby={labelId}
|
||||
aria-valuemax={max}
|
||||
aria-valuemin={min}
|
||||
aria-valuenow={getIsValidNumber(value) ? value : undefined}
|
||||
aria-valuetext={valueText}
|
||||
data-state={state}
|
||||
data-value={value ?? undefined}
|
||||
data-max={max}
|
||||
data-min={min}
|
||||
data-percentage={percentage}
|
||||
{...rootProps}
|
||||
className={cn(
|
||||
"relative inline-flex w-fit flex-col items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</GaugeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeIndicator(props: React.ComponentProps<"svg">) {
|
||||
const { className, ...indicatorProps } = props;
|
||||
|
||||
const { size, state, value, max, min, percentage } =
|
||||
useGaugeContext(INDICATOR_NAME);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
data-state={state}
|
||||
data-value={value ?? undefined}
|
||||
data-max={max}
|
||||
data-min={min}
|
||||
data-percentage={percentage}
|
||||
width={size}
|
||||
height={size}
|
||||
{...indicatorProps}
|
||||
className={cn("transform", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeTrack(props: PathProps) {
|
||||
const { className, ...trackProps } = props;
|
||||
|
||||
const { center, radius, startAngle, endAngle, thickness, state } =
|
||||
useGaugeContext(TRACK_NAME);
|
||||
|
||||
const pathData = describeArc(center, center, radius, startAngle, endAngle);
|
||||
|
||||
return (
|
||||
<path
|
||||
data-state={state}
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
{...trackProps}
|
||||
className={cn("text-muted-foreground/20", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeRange(props: PathProps) {
|
||||
const { className, ...rangeProps } = props;
|
||||
|
||||
const {
|
||||
center,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
value,
|
||||
max,
|
||||
min,
|
||||
state,
|
||||
thickness,
|
||||
arcLength,
|
||||
percentage,
|
||||
} = useGaugeContext(RANGE_NAME);
|
||||
|
||||
const pathData = describeArc(center, center, radius, startAngle, endAngle);
|
||||
|
||||
const strokeDasharray = arcLength;
|
||||
const strokeDashoffset =
|
||||
state === "indeterminate"
|
||||
? 0
|
||||
: percentage !== null
|
||||
? arcLength - percentage * arcLength
|
||||
: arcLength;
|
||||
|
||||
return (
|
||||
<path
|
||||
data-state={state}
|
||||
data-value={value ?? undefined}
|
||||
data-max={max}
|
||||
data-min={min}
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
{...rangeProps}
|
||||
className={cn(
|
||||
"text-primary transition-[stroke-dashoffset] duration-700 ease-out",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeValueText(props: DivProps) {
|
||||
const { asChild, className, children, style, ...valueTextProps } = props;
|
||||
|
||||
const { valueTextId, state, arcCenterY, valueText } =
|
||||
useGaugeContext(VALUE_TEXT_NAME);
|
||||
|
||||
const ValueTextPrimitive = asChild ? SlotPrimitive.Slot : "div";
|
||||
|
||||
return (
|
||||
<ValueTextPrimitive
|
||||
id={valueTextId}
|
||||
data-state={state}
|
||||
{...valueTextProps}
|
||||
style={{
|
||||
top: `${arcCenterY}px`,
|
||||
...style,
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 left-0 flex -translate-y-1/2 items-center justify-center font-semibold text-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ?? valueText}
|
||||
</ValueTextPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeLabel(props: DivProps) {
|
||||
const { asChild, className, ...labelProps } = props;
|
||||
|
||||
const { labelId, state } = useGaugeContext(LABEL_NAME);
|
||||
|
||||
const LabelPrimitive = asChild ? SlotPrimitive.Slot : "div";
|
||||
|
||||
return (
|
||||
<LabelPrimitive
|
||||
id={labelId}
|
||||
data-state={state}
|
||||
{...labelProps}
|
||||
className={cn(
|
||||
"mt-2 font-medium text-muted-foreground text-sm",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeCombined(props: GaugeProps) {
|
||||
return (
|
||||
<Gauge {...props}>
|
||||
<GaugeIndicator>
|
||||
<GaugeTrack />
|
||||
<GaugeRange />
|
||||
</GaugeIndicator>
|
||||
<GaugeValueText />
|
||||
</Gauge>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Gauge,
|
||||
GaugeCombined,
|
||||
GaugeIndicator,
|
||||
GaugeLabel,
|
||||
type GaugeProps,
|
||||
GaugeRange,
|
||||
GaugeTrack,
|
||||
GaugeValueText,
|
||||
};
|
||||
26
apps/frontend/src/components/ui/separator.tsx
Normal file
26
apps/frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
147
apps/frontend/src/components/ui/sheet.tsx
Normal file
147
apps/frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
56
apps/frontend/src/components/ui/tooltip.tsx
Normal file
56
apps/frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-w-sm rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 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 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user