Initialize project and add Netify API integration with TDD workflow
This commit is contained in:
1 parent
8a077cf692
commit
da8fb57bfb
43 files changed
+9230
-115
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan";
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
"border-primary/20 bg-primary/10 text-primary": variant === "default",
|
||||
"border-red-500/20 bg-red-500/10 text-red-500": variant === "red",
|
||||
"border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange",
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow",
|
||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green",
|
||||
"border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue",
|
||||
"border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export interface Column<T> {
|
||||
header: string;
|
||||
accessor: (row: T, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
searchPlaceholder?: string;
|
||||
searchFilter?: (row: T, query: string) => boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
searchPlaceholder = "Search...",
|
||||
searchFilter,
|
||||
isLoading
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredData = searchFilter
|
||||
? data.filter(row => searchFilter(row, query))
|
||||
: data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{searchFilter && (
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card/50 backdrop-blur-xl shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th key={i} className={`px-4 py-3.5 font-medium whitespace-nowrap tracking-wide ${col.className || ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-3 animate-pulse">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl"></div>
|
||||
<svg className="animate-spin relative h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium tracking-wide">Loading data...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 mb-3 opacity-20" />
|
||||
<span className="text-sm font-medium">No data found</span>
|
||||
{query && <span className="text-xs mt-1 opacity-70">Try adjusting your search query</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredData.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-primary/5 transition-colors duration-200 group">
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-4 py-3 whitespace-nowrap text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isMounted || !isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-50 w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl transition-all sm:my-8",
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TabProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tab({ children }: TabProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
children: React.ReactElement<TabProps>[];
|
||||
}
|
||||
|
||||
export function Tabs({ children }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex space-x-1 border-b border-white/10 overflow-x-auto pb-1">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (!React.isValidElement(child)) return null;
|
||||
const isActive = index === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium rounded-t-lg transition-colors whitespace-nowrap
|
||||
${isActive
|
||||
? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
{child.props.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (index !== activeTab) return null;
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Sphere,
|
||||
Graticule,
|
||||
ZoomableGroup
|
||||
} from "react-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
// The topojson file containing the world shapes
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// Our custom heatmap color scale
|
||||
// Maps values from 0 to maxValue to a gradient (Dark Blue to Electric Cyan/Purple)
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([0, 100]) // We will update the domain dynamically based on data
|
||||
.range(["#1e293b", "#38bdf8"]); // Tailwind slate-800 to sky-400
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string; // ISO A2 code
|
||||
value: number; // The value used for heatmap
|
||||
label: string; // Country name for tooltip
|
||||
}[];
|
||||
}
|
||||
|
||||
const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
const [tooltipContent, setTooltipContent] = useState("");
|
||||
const [maxValue, setMaxValue] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const max = Math.max(...data.map(d => d.value));
|
||||
setMaxValue(max);
|
||||
colorScale.domain([0, max]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-[2/1] bg-secondary/20 rounded-xl overflow-hidden border border-border/50 backdrop-blur-sm">
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-card/80 p-3 rounded-lg border border-border/50 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Heatmap Activity
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
<div className="w-24 h-2 rounded-full bg-gradient-to-r from-slate-800 to-sky-400 shadow-sm"></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip Overlay */}
|
||||
{tooltipContent && (
|
||||
<div className="absolute bottom-4 left-4 z-10 bg-card/90 backdrop-blur-md border border-border/50 px-4 py-2.5 rounded-lg shadow-lg pointer-events-none animate-fade-in">
|
||||
<div className="text-sm font-semibold text-white">{tooltipContent.split(':')[0]}</div>
|
||||
<div className="text-xs text-sky-400 mt-1 font-medium">{tooltipContent.split(':')[1]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 130,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<ZoomableGroup zoom={1} maxZoom={4} translateExtent={[[-100, -100], [900, 500]]}>
|
||||
<Sphere stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} id="sphere" fill="transparent" />
|
||||
<Graticule stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
// geo.properties.name contains the country name in this TopoJSON
|
||||
// Try to find if we have data for this country
|
||||
const d = data.find((s) => {
|
||||
if (!geo.properties.name) return false;
|
||||
const geoName = geo.properties.name.toLowerCase();
|
||||
const sLabel = s.label.toLowerCase();
|
||||
|
||||
return geoName === sLabel ||
|
||||
(sLabel === 'united states' && geoName === 'united states of america') ||
|
||||
(sLabel === 'the netherlands' && geoName === 'netherlands') ||
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const color = d ? colorScale(d.value) : "#1e293b"; // Default color
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={color}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: {
|
||||
fill: "#0ea5e9", // sky-500 on hover
|
||||
outline: "none",
|
||||
transition: "all 200ms ease"
|
||||
},
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (d) {
|
||||
setTooltipContent(`${d.label}: ${fmtBytes(d.value)}`);
|
||||
} else {
|
||||
setTooltipContent(`${geo.properties.name}: No data`);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltipContent("");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorldMap = memo(WorldMapComponent);
|
||||
Reference in new issue
Block a user