This commit is contained in:
Alois 2026-04-02 22:39:50 +02:00
commit a18640344e
77 changed files with 8201 additions and 2 deletions

45
.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
out/
# Jonathan
package-lock.json

16
LICENSE Normal file
View file

@ -0,0 +1,16 @@
Copyright (c) [2025] [Methanium]
All rights reserved.
This software is protected by copyright. Copying, editing,
distributing, publicly performing, or any other use of this software
or its components, in source or binary form, is strictly prohibited without the express
written permission of the copyright holder.
FUTURE LICENSE ACCEPTANCE:
It is the copyright holder's intention to release this software in the future
under a license yet to be defined, which will, among other things,
allow private, non-commercial use. This statement does not constitute
a current license grant and does not alter the above
prohibition on use, copying, or modification. Until the formal
publication of such a future license, all rights remain
reserved.

View file

@ -1,2 +1,2 @@
# iota-frontend
# Iota-Frontend
Interactive Web UI for Iota

1675
bun.lock Normal file

File diff suppressed because it is too large Load diff

22
components.json Normal file
View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

22
next.config.ts Normal file
View file

@ -0,0 +1,22 @@
import type { NextConfig } from "next";
import { execSync } from "child_process";
// Get the current git commit hash
const getGitCommitHash = () => {
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch (error) {
console.warn("Could not get git commit hash:", error);
return "unknown";
}
};
const nextConfig: NextConfig = {
output: "export",
reactCompiler: true,
env: {
NEXT_PUBLIC_GIT_COMMIT: getGitCommitHash(),
},
};
export default nextConfig;

52
package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "tensamin_frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.553.0",
"next": "^16.0.7",
"next-themes": "^0.4.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-resizable-panels": "^3.0.6",
"recharts": "^2.15.4",
"shadcn": "^3.5.1",
"sonner": "^2.0.7",
"swr": "^2.3.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^20.19.25",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.9.2",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.7",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
}
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

3
src/app/about/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function About() {
return <div>About page</div>;
}

BIN
src/app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -0,0 +1,3 @@
export default function Communities() {
return <div>Communities</div>;
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

126
src/app/globals.css Normal file
View file

@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--chart-cpu: #BCEDF6;
--chart-memory: #6B7FD7;
--chart-network: #4C2A85;
--chart-ping: #661e78;
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

3
src/app/icon0.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 875 KiB

BIN
src/app/icon1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

75
src/app/layout.tsx Normal file
View file

@ -0,0 +1,75 @@
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ThemeProvider } from "@/components/themeProvider";
import "./globals.css";
import React from "react";
import { Separator } from "@/components/ui/separator";
import { AppSidebar } from "@/components/app-sidebar";
import type { Metadata } from "next";
import { DynamicBreadcrumb } from "@/components/breadcrumb";
import { Rubik } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { Search } from "@/components/search";
const rubik = Rubik({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-rubik",
});
export const metadata: Metadata = {
title: "Iota",
description: "Manage your Iota",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${rubik.variable} font-sans antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Toaster></Toaster>
<div className="flex h-full">
<div className="h-full w-full">
<SidebarProvider
style={
{
"--sidebar-width": "19rem",
} as React.CSSProperties
}
defaultOpen={false}
>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center justify-between gap-2 px-4">
<div className="flex gap-2 items-center">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<DynamicBreadcrumb />
</div>
<Search />
</header>
{children}
</SidebarInset>
</SidebarProvider>
</div>
</div>
</ThemeProvider>
</body>
</html>
);
}

36
src/app/not-found.tsx Normal file
View file

@ -0,0 +1,36 @@
import { Search } from "@/components/search";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import Link from "next/link";
export default function NotFound() {
return (
<Empty>
<EmptyHeader>
<EmptyTitle>404 - Not Found</EmptyTitle>
<EmptyDescription>
The page you&apos;re looking for doesn&apos;t exist. Try searching for what you
need below.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Search />
<EmptyDescription>
Need help?{" "}
<Link
href="https://docs.tensamin.net"
target="_blank"
className="p-0 text-muted-foreground"
>
Check the docs
</Link>
</EmptyDescription>
</EmptyContent>
</Empty>
);
}

5
src/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { General } from "@/components/general";
export default function HomePage() {
return <General />;
}

18
src/app/settings/page.tsx Normal file
View file

@ -0,0 +1,18 @@
import {
Card,
CardContent,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export default function Settings() {
return (
<Card className="p-4 m-4">
<CardTitle>Settings</CardTitle>
<CardContent>
How many points in the chart? <Input defaultValue={"500"} />
</CardContent>
</Card>
);
}

45
src/app/users/page.tsx Normal file
View file

@ -0,0 +1,45 @@
"use client";
import { Users } from "@/components/users";
import { UsersEmpty } from "@/components/users/empty";
import { UsersError } from "@/components/users/error";
import { UsersLoading } from "@/components/users/loading";
import { useUsers } from "@/hooks/use-users";
export default function UsersPage() {
const { users, error, isLoading, refreshUsers, deleteUser } = useUsers();
if (error)
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
<div className="flex-1 flex items-center justify-center">
<div className="w-full">
<UsersError onRetry={refreshUsers} />
</div>
</div>
</div>
);
if (isLoading) return <UsersLoading />;
if (!isLoading && (!users || users.length === 0)) {
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md">
<UsersEmpty onUserCreated={refreshUsers} />
</div>
</div>
</div>
);
}
return (
<Users
users={users || []}
onRefresh={refreshUsers}
isLoading={isLoading}
onDeleteUser={deleteUser}
/>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import type { ComponentProps } from "react";
import {
Home,
Users,
HouseWifi,
Settings,
} from "lucide-react";
import { NavMain } from "@/components/sidebar";
import { NavPower } from "@/components/sidebar/power";
import { NavInfo } from "@/components/sidebar/info";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar";
const items = [
{ title: "General", url: "/", icon: Home },
{ title: "Users", url: "/users", icon: Users },
{ title: "Communities", url: "/communities", icon: HouseWifi },
{ title: "Settings", url: "/settings", icon: Settings },
];
export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<NavInfo />
</SidebarHeader>
<SidebarContent>
<NavMain items={items} />
</SidebarContent>
<SidebarFooter>
<NavPower />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View file

@ -0,0 +1,50 @@
'use client';
import { usePathname } from "next/navigation";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb";
import React from "react";
export function DynamicBreadcrumb() {
const pathname = usePathname();
const pathParts = pathname.split("/").filter(Boolean);
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const breadcrumbs = [];
breadcrumbs.push(
<BreadcrumbItem key="home">
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>,
);
let accumulated = "";
pathParts.forEach((part) => {
accumulated += `/${part}`;
const isLast = accumulated === pathname;
if (!isLast) {
breadcrumbs.push(
<React.Fragment key={`${accumulated}-link`}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={accumulated}>
{capitalize(part)}
</BreadcrumbLink>
</BreadcrumbItem>
</React.Fragment>,
);
} else {
breadcrumbs.push(
<React.Fragment key={`${accumulated}-page`}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{capitalize(part)}</BreadcrumbPage>
</BreadcrumbItem>
</React.Fragment>,
);
}
});
return (
<Breadcrumb>
<BreadcrumbList>{breadcrumbs}</BreadcrumbList>
</Breadcrumb>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { cn } from "@/lib/utils";
export type ChartSelectOption = {
key: string;
label: string;
value?: number | string;
colorVar: string;
active: boolean;
};
interface ChartSelectProps {
options: ChartSelectOption[];
onToggle: (key: ChartSelectOption["key"]) => void;
className?: string;
}
export function ChartSelect({
options,
onToggle,
className,
}: ChartSelectProps) {
return (
<div
className={cn(
"flex w-full h-full justify-between flex-wrap gap-3 md:w-35 md:flex-col",
className,
)}
>
{options.map((option) => (
<button
key={option.key}
type="button"
onClick={() => onToggle(option.key)}
className={cn(
"flex min-w-[140px] flex-1 flex-col items-start justify-center gap-1 rounded-xl border border-border/60 bg-background/30 p-3 text-left transition hover:border-border h-fit",
option.active && "bg-background/60 shadow-sm",
)}
aria-pressed={option.active}
>
<span
className="h-3 w-3 pb-3 rounded-full bg-muted-foreground duration-200 transition-colors"
style={{ backgroundColor: option.active ? option.colorVar : undefined }}
/>
<span
className="text-2xl font-semibold leading-tight text-muted-foreground duration-200 transition-colors"
style={{ color: option.active ? option.colorVar : undefined }}
>
{option.value ?? "--"}
</span>
<span className={`text-sm font-semibold transition-colors duration-200 ${!option.active ? "text-muted-foreground" : "text-foreground"}`}>
{option.label}
</span>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { Separator } from "../ui/separator";
import { Resources } from "./resources";
export function General() {
return (
<div className="w-full h-full p-4">
<div
className="grid h-full gap-4"
style={{ gridTemplateRows: "65% 30%" }}
>
<div className="min-h-0">
<Resources />
</div>
<div className="min-h-0 pt-8">
<Separator></Separator>
<div className="flex h-full w-full flex-col overflow-hidden p-4">
<div className="text-lg font-semibold">Logs</div>
<div className="mt-4 flex-1 overflow-auto text-sm text-muted-foreground">
{/* Logs content goes here */}
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,235 @@
import { useMemo, useState } from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import useSWR from "swr";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { ChartSelect, type ChartSelectOption } from "./chartSelect";
const MAX_POINTS = 60;
const INTERVAL_MS = 15000;
const LINE_ANIMATION = {
duration: 900,
delay: 120,
easing: "ease-out" as const,
};
const chartConfig = {
cpu_usage: {
label: "CPU Usage",
color: "var(--chart-1)",
},
ram_usage: {
label: "RAM Usage",
color: "var(--chart-2)",
},
network_usage: {
label: "Network Activity",
color: "var(--chart-3)",
},
ping_usage: {
label: "Ping (ms)",
color: "var(--chart-4)",
},
} satisfies ChartConfig;
const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface MetricsResponse {
cpu: number[];
ram: number[];
ping: number[];
net_up: number[];
net_down: number[];
}
interface ChartPoint {
index: number;
cpu_usage: number;
ram_usage: number;
ping_usage: number;
network_usage: number;
}
type MetricKey = "cpu_usage" | "ram_usage" | "network_usage" | "ping_usage";
type MetricOption = {
key: MetricKey;
label: string;
colorVar: string;
};
const METRIC_OPTIONS: MetricOption[] = [
{
key: "cpu_usage",
label: "CPU Usage",
colorVar: "var(--chart-2)",
},
{
key: "ram_usage",
label: "RAM Usage",
colorVar: "var(--chart-3)",
},
{
key: "network_usage",
label: "Network",
colorVar: "var(--chart-4)",
},
{
key: "ping_usage",
label: "Ping",
colorVar: "var(--chart-1)",
},
];
const formatMetricValue = (key: MetricKey, value?: number) => {
if (value == null) {
return undefined;
}
switch (key) {
case "cpu_usage":
case "ram_usage":
return `${Math.round(value)}%`;
case "ping_usage":
return `${Math.round(value)} ms`;
case "network_usage":
return `${value.toFixed(1)} Mbps`;
default:
return value.toString();
}
};
export function Resources() {
const { data } = useSWR<MetricsResponse>("/api/app_state", fetcher, {
refreshInterval: INTERVAL_MS,
});
const indices = Array.from({ length: data?.cpu.length ?? 0 }, (_, i) => i);
const limitedIndices = indices.slice(-MAX_POINTS);
const chartData: ChartPoint[] = limitedIndices.map((i) => ({
index: i,
cpu_usage: data?.cpu[i] ?? 0,
ram_usage: data?.ram[i] ?? 0,
ping_usage: data?.ping[i] ?? 0,
network_usage: (data?.net_up[i] ?? 0) + (data?.net_down[i] ?? 0),
}));
const latestPoint = chartData.at(-1);
const [visibleMetrics, setVisibleMetrics] = useState<
Record<MetricKey, boolean>
>({
cpu_usage: true,
ram_usage: true,
network_usage: true,
ping_usage: true,
});
const toggleMetric = (key: ChartSelectOption["key"]) => {
const metricKey = key as MetricKey;
setVisibleMetrics((prev) => ({
...prev,
[metricKey]: !prev[metricKey],
}));
};
const chartOptions: ChartSelectOption[] = useMemo(() => {
return METRIC_OPTIONS.map((option) => ({
key: option.key,
label: option.label,
colorVar: option.colorVar,
active: visibleMetrics[option.key],
value: latestPoint
? formatMetricValue(option.key, latestPoint[option.key])
: undefined,
}));
}, [visibleMetrics, latestPoint]);
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-col-reverse items-start gap-4 md:flex-row md:h-full md:min-h-0">
<div className="flex-1 min-w-0">
<div className="h-full">
<ChartContainer
config={chartConfig}
className="aspect-auto w-full h-[40vh] md:h-[60vh]"
>
<LineChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
className="hidden"
dataKey="index"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent />}
/>
<Line
className={visibleMetrics.cpu_usage ? "visible" : "hidden"}
dataKey="cpu_usage"
type="monotone"
stroke="var(--chart-2)"
strokeWidth={3}
dot={false}
isAnimationActive
animationDuration={LINE_ANIMATION.duration}
animationBegin={LINE_ANIMATION.delay}
animationEasing={LINE_ANIMATION.easing}
/>
<Line
className={visibleMetrics.ram_usage ? "visible" : "hidden"}
dataKey="ram_usage"
type="monotone"
stroke="var(--chart-3)"
strokeWidth={3}
dot={false}
isAnimationActive
animationDuration={LINE_ANIMATION.duration}
animationBegin={LINE_ANIMATION.delay}
animationEasing={LINE_ANIMATION.easing}
/>
<Line
className={
visibleMetrics.network_usage ? "visible" : "hidden"
}
dataKey="network_usage"
type="monotone"
stroke="var(--chart-4)"
strokeWidth={3}
dot={false}
isAnimationActive
animationDuration={LINE_ANIMATION.duration}
animationBegin={LINE_ANIMATION.delay}
animationEasing={LINE_ANIMATION.easing}
/>
<Line
className={visibleMetrics.ping_usage ? "visible" : "hidden"}
dataKey="ping_usage"
type="monotone"
stroke="var(--chart-1)"
strokeWidth={3}
dot={false}
isAnimationActive
animationDuration={LINE_ANIMATION.duration}
animationBegin={LINE_ANIMATION.delay}
animationEasing={LINE_ANIMATION.easing}
/>
</LineChart>
</ChartContainer>
</div>
</div>
<ChartSelect options={chartOptions} onToggle={toggleMetric} />
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,147 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "../ui/input-group";
import { Kbd, KbdGroup } from "../ui/kbd";
import {
Activity,
Book,
ExternalLink,
SearchIcon,
Settings,
TextSearch,
User,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "../ui/command";
import { Button } from "../ui/button";
type CommandEntry = {
label: string;
icon: LucideIcon;
shortcut?: string;
href?: string;
external?: boolean;
};
const suggestionCommands: CommandEntry[] = [
{ label: "Status", icon: Activity, href: "/" },
{ label: "Logs", icon: TextSearch, href: "/" },
{ label: "Users", icon: User, href: "/users" },
];
const Commands: CommandEntry[] = [
{ label: "Docs", icon: Book, external: true, href: "https://docs.tensamin.net" },
{ label: "App", icon: ExternalLink, external: true, href: "https://app.tensamin.net" },
{ label: "Settings", icon: Settings, shortcut: "⌘S", href: "/settings" },
];
export function Search() {
const [open, setOpen] = React.useState(false);
const router = useRouter();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const handleCommandSelect = (command: CommandEntry) => {
setOpen(false);
if (command.external && command.href) {
window.open(command.href, "_blank");
return;
}
if (command.href) {
router.push(command.href);
}
};
return (
<>
<InputGroup
className="max-w-[15vw] cursor-pointer"
onClick={() => setOpen(!open)}
>
<InputGroupInput placeholder="Search..." disabled />
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<KbdGroup>
<Kbd></Kbd>+<Kbd>J</Kbd>
</KbdGroup>
</InputGroupAddon>
</InputGroup>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>
<div className="flex-col w-full items-center justify-center">
<p>No results found. Didn&apos;t find what you&apos;re looking for?</p>
<Button
variant="link"
className="p-0"
onClick={() =>
window.open("https://docs.tensamin.net", "_blank")
}
>
Open docs.tensamin.net
</Button>
</div>
</CommandEmpty>
<CommandGroup heading="Suggestions">
{suggestionCommands.map((command) => {
const Icon = command.icon;
return (
<CommandItem
key={command.label}
onSelect={() => handleCommandSelect(command)}
>
<Icon />
<span>{command.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Commands">
{Commands.map((command) => {
const Icon = command.icon;
return (
<CommandItem
key={command.label}
onSelect={() => handleCommandSelect(command)}
>
<Icon />
<span>{command.label}</span>
{command.shortcut && (
<CommandShortcut>{command.shortcut}</CommandShortcut>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
</>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { ActivityIcon, GitBranchIcon } from "lucide-react";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { Button } from "../ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function NavInfo() {
const { isMobile } = useSidebar();
return (
<SidebarMenu>
<SidebarMenuItem>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
asChild
>
<Button
variant={"ghost"}
onClick={() => {
window.open("https://app.tensamin.net", "_blank");
}}
className="w-full h-full items-center justify-start px-2 cursor-pointer"
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<ActivityIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Iota</span>
{/* From which git commit this version was built (in dev) or the current version in prod */}
<span className="truncate text-xs flex items-center justify-start gap-1 text-muted-foreground">
<GitBranchIcon className="size-3.5"></GitBranchIcon>{" "}
<p>{process.env.NEXT_PUBLIC_GIT_COMMIT || "dev"}</p>
</span>
</div>
</Button>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={isMobile}
>
<p>Open Tensamin</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,153 @@
"use client";
import { ChevronsUpDown, LogOut, Power, RotateCcw } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import useSWRMutation from "swr/mutation";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { Button } from "../ui/button";
import { toast } from "sonner";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
type PowerResponse = {
type?: string;
[key: string]: unknown;
};
function usePowerAction(endpoint: string, errorLabel: string) {
return useSWRMutation(endpoint, async (url: string) => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
toast.error(
`Failed to ${errorLabel}: ${response.status} ${response.statusText}`,
);
throw new Error(`${errorLabel} request failed`);
}
return (await response.json()) as PowerResponse;
});
}
export function NavPower() {
const { state, isMobile } = useSidebar();
const { trigger: triggerShutdown, isMutating: isShuttingDown } =
usePowerAction("/api/shutdown/", "Shutdown");
const { trigger: triggerRestart, isMutating: isRestarting } = usePowerAction(
"/api/reload/",
"Restart",
);
async function handlePowerAction(
trigger: () => Promise<PowerResponse | undefined>,
action: string,
) {
try {
const data = await trigger();
if (data?.type === "success") {
window.location.reload();
}
} catch (error) {
console.error(`Failed to ${action}`, error);
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
asChild
>
<Button
variant={"destructive"}
className="w-full h-full items-center justify-start px-2"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg">
<Power className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight items-center">
<span className="truncate font-medium">
Power Options
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</Button>
</SidebarMenuButton>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" && isMobile}
>
<p>Power Options</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Button
variant={"ghost"}
onClick={() =>
handlePowerAction(triggerShutdown, "shut down")
}
className="w-full justify-start"
disabled={isShuttingDown}
>
<Power />
{isShuttingDown ? "Shutting down" : "Shut down"}
</Button>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
variant={"ghost"}
onClick={() => handlePowerAction(triggerRestart, "restart")}
disabled={isRestarting}
className="w-full justify-start"
>
<RotateCcw />
{isRestarting ? "Reloading" : "Reload"}
</Button>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button variant={"ghost"} className="w-full justify-start">
<LogOut />
Log out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,11 @@
"use client"
import type { ComponentProps } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View file

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View file

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

357
src/components/ui/chart.tsx Normal file
View file

@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View file

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View file

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

104
src/components/ui/empty.tsx Normal file
View file

@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

248
src/components/ui/field.tsx Normal file
View file

@ -0,0 +1,248 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

167
src/components/ui/form.tsx Normal file
View file

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View file

@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

28
src/components/ui/kbd.tsx Normal file
View file

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View file

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
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(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

139
src/components/ui/sheet.tsx Normal file
View file

@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
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(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.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-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,731 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
const id = React.useId();
const width = React.useMemo(() => {
const hash = Array.from(id).reduce(
(acc, char) => acc + char.charCodeAt(0),
0,
);
const normalizedWidth = 50 + (hash % 41);
return `${normalizedWidth}%`;
}, [id]);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
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 (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background 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 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-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -0,0 +1,50 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { UserNew } from "@/components/users/new";
export function UsersEmpty({ onUserCreated }: { onUserCreated?: () => void }) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia>
<div className="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:size-12 *:data-[slot=avatar]:ring-2">
<Avatar>
<AvatarImage src="/assets/images/default/1.jpg" alt="user1" />
<AvatarFallback>AL</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src="/assets/images/default/2.jpg" alt="user2" />
<AvatarFallback>MZ</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src="/assets/images/default/3.jpg" alt="user3" />
<AvatarFallback>RG</AvatarFallback>
</Avatar>
</div>
</EmptyMedia>
<EmptyTitle>No Users found</EmptyTitle>
<EmptyDescription>
<p>
{" "}
Create a user to get started. If you want to transfer an existing
user from another Iota{" "}
<Button variant={"link"} className="p-0 text-muted-foreground">
check the docs.
</Button>{" "}
</p>
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<UserNew onUserCreated={onUserCreated}></UserNew>
</EmptyContent>
</Empty>
);
}

View file

@ -0,0 +1,72 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Loader2 } from "lucide-react";
import { useState } from "react";
interface UsersErrorProps {
onRetry?: () => void;
}
export function UsersError({ onRetry }: UsersErrorProps) {
const [isRetrying, setIsRetrying] = useState(false);
const handleRetry = async () => {
if (!onRetry) return;
setIsRetrying(true);
try {
await onRetry();
} catch (error) {
console.error("Retry failed:", error);
} finally {
setIsRetrying(false);
}
};
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="default">
<div className="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:size-12 *:data-[slot=avatar]:ring-2">
<Avatar>
<AvatarImage src="/assets/images/default/1.jpg" alt="user1" />
<AvatarFallback>AL</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src="/assets/images/default/2.jpg" alt="user2" />
<AvatarFallback>MZ</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src="/assets/images/default/3.jpg" alt="user3" />
<AvatarFallback>RG</AvatarFallback>
</Avatar>
</div>
</EmptyMedia>
<EmptyTitle>Well, that&apos;s awkward</EmptyTitle>
<EmptyDescription>
There was an error loading the users from your Iota, please try again
or check your logs.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm" onClick={handleRetry} disabled={isRetrying}>
{isRetrying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Retrying...
</>
) : (
"Retry"
)}
</Button>
</EmptyContent>
</Empty>
);
}

View file

@ -0,0 +1,139 @@
"use client";
import { useState, useMemo } from "react";
import { Search, Grid, List, RefreshCw } from "lucide-react";
import type { User } from "@/hooks/use-users";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { UserGridView } from "./views/grid";
import { UserListView } from "./views/list";
import { UsersEmpty } from "./empty";
import { UserNew } from "./new";
// Re-export User type for backward compatibility
export type { User };
interface UsersProps {
users: User[];
onRefresh?: () => void;
isLoading?: boolean;
onDeleteUser?: (uuid: string) => Promise<void>;
}
export function Users({
users,
onRefresh,
isLoading,
onDeleteUser,
}: UsersProps) {
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const filteredUsers = useMemo(() => {
return users.filter((user) => {
const matchesSearch =
user.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.username.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [users, searchQuery]);
const formatBytes = (kilobytes: number) => {
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
} else if (kilobytes < 1024 * 1024) {
return `${(kilobytes / 1024).toFixed(1)} MB`;
} else {
return `${(kilobytes / (1024 * 1024)).toFixed(1)} GB`;
}
};
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
<div className="shrink-0 p-6 border-b bg-background">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-1 items-center">
<Button
variant={viewMode === "grid" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("grid")}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
</Button>
{onRefresh && (
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="ml-2"
>
<RefreshCw
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
)}
<UserNew className="ml-2" noText onUserCreated={onRefresh} />
<p className="text-muted-foreground text-sm ml-3">
{filteredUsers.length} user{filteredUsers.length !== 1 ? "s" : ""}{" "}
found
</p>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{filteredUsers.length === 0 ? (
searchQuery ? (
<div className="flex flex-col items-center justify-center h-full text-center animate-in fade-in-0 slide-in-from-bottom-4 duration-300">
<h3 className="text-lg font-medium">
No users found for your search
</h3>
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-4 duration-300">
<UsersEmpty onUserCreated={onRefresh} />
</div>
)
) : viewMode === "grid" ? (
<div className="animate-in fade-in-0 slide-in-from-bottom-4 duration-300">
<UserGridView
users={filteredUsers}
formatBytes={formatBytes}
onDeleteUser={onDeleteUser}
/>
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-4 duration-300">
<UserListView
users={filteredUsers}
formatBytes={formatBytes}
onDeleteUser={onDeleteUser}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { Loader2 } from "lucide-react";
export function UsersLoading() {
return (
<div className="h-[calc(100vh-4rem)] flex flex-col items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</div>
);
}

View file

@ -0,0 +1,530 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useUsernameAvailability, type User } from "@/hooks/use-users";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Loader2, AlertCircle, Upload } from "lucide-react";
interface CreateUserProps {
onUserCreated: (user: User) => void;
onInvalidateUsers?: () => void;
}
const createUserSchema = z.object({
displayName: z
.string()
.min(1, "Display name is required")
.max(50, "Display name must be less than 50 characters")
.refine(
(value) => !/[{}\\/\[\]]/.test(value),
"Display name cannot contain [ { / \\ } ]",
),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(
/^[a-z0-9]+$/,
"Username can only contain lowercase letters and numbers",
),
});
type CreateUserFormData = z.infer<typeof createUserSchema>;
interface CreateUserRequest {
username: string;
displayname?: string;
avatar?: string;
}
interface CreateUserApiUser {
uuid: string;
username: string;
public_key: string;
private_key_hash: string;
displayname?: string;
avatar?: string;
storage?: number;
connected?: boolean;
createdAt?: number;
}
type CreateUserErrorResponse = {
type: "error";
message?: string;
};
type CreateUserSuccessResponse = {
id?: string;
type?: string;
data?: {
user?: CreateUserApiUser;
};
user?: CreateUserApiUser; // if the user is returned a the top level
} & Partial<CreateUserApiUser>;
type CreateUserResponse = CreateUserErrorResponse | CreateUserSuccessResponse;
const isCreateUserError = (
response: CreateUserResponse,
): response is CreateUserErrorResponse => response.type === "error";
export function CreateUser({ onUserCreated }: CreateUserProps) {
const [avatar, setAvatar] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isAvatarHovered, setIsAvatarHovered] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<CreateUserFormData>({
resolver: zodResolver(createUserSchema),
defaultValues: {
displayName: "",
username: "",
},
mode: "onChange",
});
const { watch, setValue } = form;
const displayName = watch("displayName");
const username = watch("username");
const [debouncedUsername, setDebouncedUsername] = useState(username);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedUsername(username);
}, 350);
return () => {
clearTimeout(handler);
};
}, [username]);
const {
isAvailable,
isTaken,
isLoading: isCheckingUsername,
} = useUsernameAvailability(debouncedUsername);
const isDebouncingUsername = username !== debouncedUsername;
const isCheckingOrDebouncing = isDebouncingUsername || isCheckingUsername;
const handleDisplayNameChange = (value: string) => {
const cleanUsername = value
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.slice(0, 20);
setValue("username", cleanUsername);
return value;
};
const compressImage = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
// dimensions to fit within 1024x1024
const maxSize = 1024;
let { width, height } = img;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = (height * maxSize) / width;
width = maxSize;
} else {
width = (width * maxSize) / height;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
// Convert to base64 with reduced quality
let quality = 0.9;
let base64 = canvas.toDataURL("image/jpeg", quality);
// Reduce quality until under 2MB
while (base64.length > 2 * 1024 * 1024 && quality > 0.1) {
quality -= 0.1;
base64 = canvas.toDataURL("image/jpeg", quality);
}
resolve(base64);
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = URL.createObjectURL(file);
});
};
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarError(null);
// Validate file type
if (!file.type.startsWith("image/")) {
setAvatarError("Please select an image file");
return;
}
if (file.size > 2 * 1024 * 1024) {
setAvatarError("Image must be smaller than 2MB");
return;
}
try {
const compressedImage = await compressImage(file);
setAvatar(compressedImage);
} catch {
setAvatarError("Failed to process image");
}
};
const createUser = async (data: CreateUserFormData): Promise<User> => {
try {
const requestBody: CreateUserRequest = {
username: data.username,
};
// Only include displayname if different from username
if (data.displayName !== data.username) {
requestBody.displayname = data.displayName;
}
if (avatar) {
requestBody.avatar = avatar;
}
const response = await fetch("/api/users/add/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const result = (await response.json()) as CreateUserResponse;
if (isCreateUserError(result)) {
throw new Error(result.message || "Failed to create user");
}
const apiUser: CreateUserApiUser | undefined =
("data" in result ? result.data?.user : undefined) ??
("user" in result ? result.user : undefined) ??
(result as CreateUserApiUser);
if (!apiUser || !apiUser.uuid || !apiUser.username) {
throw new Error("Invalid user data returned from API");
}
const newUser: User = {
uuid: apiUser.uuid,
username: apiUser.username,
displayname: apiUser.displayname,
displayName: apiUser.displayname || apiUser.username,
public_key: apiUser.public_key,
private_key_hash: apiUser.private_key_hash,
avatar: apiUser.avatar,
storage: typeof apiUser.storage === "number" ? apiUser.storage : 0,
connected:
typeof apiUser.connected === "boolean" ? apiUser.connected : false,
createdAt:
typeof apiUser.createdAt === "number"
? apiUser.createdAt
: Math.floor(Date.now() / 1000),
};
return newUser;
} catch (error) {
console.error("Error creating user:", error);
throw error;
}
};
const onSubmit = async (data: CreateUserFormData) => {
setError(null);
setIsLoading(true);
try {
if (isTaken) {
form.setError("username", {
type: "manual",
message: "Username is already taken",
});
return;
}
const newUser = await mutate("/api/users/add", createUser(data), {
optimisticData: undefined,
rollbackOnError: true,
populateCache: false,
revalidate: false,
});
if (newUser) {
await mutate(`https://omega.tensamin.net/api/get/id/${data.username}`);
onUserCreated(newUser);
}
} catch {
setError("Failed to create user. Please try again.");
} finally {
setIsLoading(false);
}
};
const generateDefaultAvatar = (name: string) => {
const colors = ["#18181b"];
const safeName = name || "User";
const color = colors[safeName.length % colors.length];
const initials = safeName
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
return { color, initials: initials || "?" };
};
const previewDisplayName = displayName || "Alex";
const previewUsername = username || "alexmehmmet";
const defaultAvatar = generateDefaultAvatar(previewDisplayName);
return (
<div className="p-6 md:p-8 min-h-[600px]">
<div className="text-center mb-6 md:mb-8">
<h2 className="text-2xl font-bold">Create User</h2>
<p className="text-muted-foreground mt-1">
Choose a name and avatar for your new account
</p>
</div>
{error && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 items-start max-w-7xl mx-auto gap-3">
<div className="flex flex-col items-center space-y-4">
<div className="relative group">
<Avatar
className="h-32 w-32 cursor-pointer transition-all duration-200 hover:ring-4 hover:ring-primary/20"
onClick={() => fileInputRef.current?.click()}
onMouseEnter={() => setIsAvatarHovered(true)}
onMouseLeave={() => setIsAvatarHovered(false)}
>
{avatar ? (
<>
<AvatarImage src={avatar} alt="Avatar preview" />
{isAvatarHovered && (
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
<Upload className="h-8 w-8 text-white" />
</div>
)}
</>
) : (
<>
<AvatarFallback
style={{ backgroundColor: defaultAvatar.color }}
className="text-white font-medium text-2xl"
>
{defaultAvatar.initials}
</AvatarFallback>
{isAvatarHovered && (
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
<Upload className="h-8 w-8 text-white" />
</div>
)}
</>
)}
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</div>
<div className="text-center">
<p className="text-sm font-medium">Click to upload avatar</p>
<p className="text-xs text-muted-foreground">Optional Max 2MB</p>
</div>
{avatarError && (
<p className="text-sm text-destructive text-center">
{avatarError}
</p>
)}
</div>
<div className="max-w-md xl:max-w-none mx-auto xl:mx-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="flex gap-4 w-full items-start">
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) =>
field.onChange(
handleDisplayNameChange(e.target.value),
)
}
placeholder="Display Name"
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="w-1/2 flex flex-col">
<FormLabel>
Username
{isCheckingOrDebouncing && (
<span className="ml-2 text-xs text-muted-foreground">
Checking...
</span>
)}
{isTaken && (
<span className="ml-2 text-xs text-red-500">
Already taken
</span>
)}
{isAvailable && username.length >= 3 && (
<span className="ml-2 text-xs text-green-500">
Available
</span>
)}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) =>
field.onChange(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9]/g, ""),
)
}
placeholder="Username"
className={`h-10 ${
isTaken
? "border-red-500 focus:border-red-500"
: isAvailable && username.length >= 3
? "border-green-500 focus:border-green-500"
: ""
}`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-3 items-center">
<Button
type="submit"
disabled={isLoading || isTaken || isCheckingOrDebouncing}
className="w-full px-8 py-4"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : isCheckingOrDebouncing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking username...
</>
) : (
"Create User"
)}
</Button>
</div>
</form>
</Form>
</div>
<div className="space-y-3">
<Card className="border-2 border-dashed border-muted-foreground/25 p-0">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
{avatar ? (
<AvatarImage src={avatar} alt="Avatar preview" />
) : (
<AvatarFallback
style={{ backgroundColor: defaultAvatar.color }}
className="text-white font-medium text-lg"
>
{defaultAvatar.initials}
</AvatarFallback>
)}
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium text-lg truncate">
{previewDisplayName}
</p>
<p className="text-muted-foreground truncate">
@{previewUsername}
</p>
</div>
</div>
</CardContent>
</Card>
<div className="text-center">
<p className="text-sm text-muted-foreground">
This is how your profile will appear to others
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,218 @@
"use client";
import { useId, useState } from "react";
import { mutate } from "swr";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, Trash2, Shield, CheckCircle } from "lucide-react";
import { Label } from "@/components/ui/label";
import type { User } from "@/hooks/use-users";
interface DownloadUserProps {
user: User;
onDownloaded: () => void;
hasDownloaded: boolean;
onClose: () => void;
}
export function DownloadUser({
user,
onDownloaded,
hasDownloaded,
onClose,
}: DownloadUserProps) {
const [hasSafelyStored, setHasSafelyStored] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const generateTUFile = () => {
const tuFileContent = `${user.uuid}::${user.private_key_hash}`;
return tuFileContent;
};
const downloadTUFile = () => {
const tuContent = generateTUFile();
const blob = new Blob([tuContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${user.username}.tu`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
onDownloaded();
};
const deleteFromIota = async () => {
setIsDeleting(true);
try {
/*
await mutate(
`/api/users/remove`,
fetch("/api/users/remove/key", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: user.username,
}),
}).then((res) => res.json()),
{
optimisticData: undefined,
rollbackOnError: true,
populateCache: false,
revalidate: false,
},
);
*/
await mutate("/api/users/get/");
onClose();
} catch (error) {
console.error("Error deleting user private key:", error);
onClose();
} finally {
setIsDeleting(false);
}
};
const generateDefaultAvatar = () => {
const colors = ["#18181b"];
const displayName =
user.displayName || user.displayname || user.username || "User";
const color = colors[displayName.length % colors.length];
const initials = displayName
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
return { color, initials };
};
const defaultAvatar = generateDefaultAvatar();
const stored = useId();
return (
<div className="p-6 md:p-8 space-y-6 max-w-2xl mx-auto">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="h-16 w-16 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center">
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
</div>
<h2 className="text-2xl font-bold">User Created</h2>
<p className="text-muted-foreground mt-1">
Your account has been created
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-center">
How you will appear to others
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center gap-4">
<Avatar className="h-16 w-16">
{user.avatar ? (
<AvatarImage src={user.avatar} alt={user.displayName} />
) : (
<AvatarFallback
style={{ backgroundColor: defaultAvatar.color }}
className="text-white font-medium text-lg"
>
{defaultAvatar.initials}
</AvatarFallback>
)}
</Avatar>
<div className="text-center">
<p className="text-lg font-medium">{user.displayName}</p>
<p className="text-muted-foreground">@{user.username}</p>
</div>
</div>
</CardContent>
</Card>
<Alert>
<Shield className="h-4 w-4" />
<AlertDescription>
<strong>Important:</strong> This is your private key, which is used to
log in and decrypt your data. Keep it safe and secure. If you lose
this file, you will lose access to your account and no one will be
able to recover it, not even Tensamin nor anyone else.
</AlertDescription>
</Alert>
<div className="space-y-4">
{!hasDownloaded && (
<Button
onClick={downloadTUFile}
className="w-full h-12 text-lg"
size="lg"
disabled={hasDownloaded}
>
<Download className="h-5 w-5 mr-2" />
Download your Private Key
</Button>
)}
{hasDownloaded && (
<div className="space-y-4 animate-in fade-in slide-in-from-top duration-300">
<div className="flex items-start space-x-2 p-4 bg-muted/50 rounded-lg">
<Checkbox
id={stored}
checked={hasSafelyStored}
onCheckedChange={(checked: boolean) =>
setHasSafelyStored(checked)
}
className="mt-1"
/>
<Label
htmlFor={stored}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have stored my Private Key in a secure location, and
understand that if I lose it, I will lose access to my account
and my data permanently.
</Label>
</div>
<div className="space-y-2 flex flex-col">
<Button
variant="destructive"
onClick={deleteFromIota}
disabled={!hasSafelyStored || isDeleting}
className="w-full"
>
{isDeleting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin border-2 border-background border-t-transparent rounded-full" />
Deleting from Iota...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
Delete File from Iota (No way to recover)
</>
)}
</Button>
</div>
</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center space-y-1">
<p>The TU file will remain on the Iota until you delete it.</p>
<p>You can access your account by importing this TU file.</p>
</div>
</div>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { UserCreationPopup } from "./popup";
export function UserNew({
className,
noText,
onUserCreated,
}: {
className?: string;
noText?: boolean;
onUserCreated?: () => void;
}) {
const [open, setOpen] = useState(false);
const popupRef = useRef<{ canClose: () => boolean; forceClose: () => void }>(
null,
);
const handleOpenChange = (newOpen: boolean) => {
// If trying to close the dialog
if (!newOpen && open) {
const canClose = popupRef.current?.canClose() ?? true;
if (!canClose) {
// Don't close, the popup will show the warning
return;
}
}
setOpen(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" className={className}>
<PlusIcon />
{!noText && "Create User"}
</Button>
</DialogTrigger>
<UserCreationPopup
ref={popupRef}
onForceClose={() => setOpen(false)}
onClose={onUserCreated}
/>
</Dialog>
);
}

View file

@ -0,0 +1,176 @@
"use client";
import { useState, forwardRef, useImperativeHandle } from "react";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CreateUser } from "./create";
import { DownloadUser } from "./download";
import type { User } from "@/hooks/use-users";
type PageType = "create" | "download";
export const UserCreationPopup = forwardRef<
{ canClose: () => boolean; forceClose: () => void },
{ onForceClose: () => void; onClose?: () => void }
>(({ onForceClose, onClose }, ref) => {
const [currentPage, setCurrentPage] = useState<PageType>("create");
const [user, setUser] = useState<User | null>(null);
const [hasDownloaded, setHasDownloaded] = useState(false);
const [showCloseWarning, setShowCloseWarning] = useState(false);
const [isSliding, setIsSliding] = useState(false);
const handleUserCreated = (createdUser: User) => {
setUser(createdUser);
slideToPage("download");
};
const slideToPage = (page: PageType) => {
setIsSliding(true);
setTimeout(() => {
setCurrentPage(page);
setIsSliding(false);
}, 150);
};
const handleDownloaded = () => {
setHasDownloaded(true);
setShowCloseWarning(false);
};
const handleCancel = () => {
// Just reset to initial state, the dialog will close when user clicks outside
setCurrentPage("create");
setUser(null);
setHasDownloaded(false);
setShowCloseWarning(false);
setIsSliding(false);
};
const handleClose = () => {
// Reset state and actually close the dialog
handleCancel();
// Invalidate users list when closing
onClose?.();
onForceClose();
};
const forceClose = () => {
setShowCloseWarning(false);
// Reset state and let dialog close naturally
handleCancel();
// Invalidate users list when closing
onClose?.();
onForceClose();
};
const handleCloseAttempt = () => {
if (user && !hasDownloaded) {
setShowCloseWarning(true);
return false; // Prevent close
}
return true; // Allow close
};
// Expose methods to parent component
useImperativeHandle(ref, () => ({
canClose: () => {
if (user && !hasDownloaded) {
setShowCloseWarning(true);
return false;
}
return true;
},
forceClose: () => {
forceClose();
},
}));
return (
<DialogContent
className="max-w-6xl min-w-[800px] w-full max-h-[90vh] overflow-hidden p-0 selection:border-0"
onPointerDownOutside={(e) => {
if (!handleCloseAttempt()) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
if (!handleCloseAttempt()) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
if (!handleCloseAttempt()) {
e.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
if (!handleCloseAttempt()) {
e.preventDefault();
}
}}
>
<DialogTitle className="sr-only">
{currentPage === "create"
? "Create New User"
: "Download your Private Key"}
</DialogTitle>
<div className="relative overflow-hidden h-full">
{showCloseWarning && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-6 animate-in fade-in duration-300">
<Alert className="max-w-md animate-in slide-in-from-bottom-4 duration-500 ease-out">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="mt-2">
<p className="mb-4">
You haven&apos;t downloaded your private key yet, which you
need to access your account. The private key will remain on
the Iota.
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowCloseWarning(false)}
>
No, take me back
</Button>
<Button variant="destructive" size="sm" onClick={forceClose}>
Close anyway
</Button>
</div>
</AlertDescription>
</Alert>
</div>
)}
<div
className={`flex transition-transform duration-300 ease-in-out ${
isSliding ? "opacity-50" : "opacity-100"
}`}
style={{
transform: `translateX(${currentPage === "create" ? "0%" : "-50%"})`,
width: "200%",
}}
>
<div className="w-1/2 shrink-0">
<CreateUser onUserCreated={handleUserCreated} />
</div>
<div className="w-1/2 shrink-0">
{user && (
<DownloadUser
user={user}
onDownloaded={handleDownloaded}
hasDownloaded={hasDownloaded}
onClose={handleClose}
/>
)}
</div>
</div>
</div>
</DialogContent>
);
});
UserCreationPopup.displayName = "UserCreationPopup";

View file

@ -0,0 +1,309 @@
import { useState, useId } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import {
Copy,
Check,
Trash2,
ArrowRight,
User,
Key,
HardDrive,
Calendar,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface UserPopupProps {
username?: string;
displayName?: string;
public_key?: string;
uuid?: string;
avatar?: string;
connected?: boolean;
storage?: number; // in kilobytes
createdAt?: number; // Unix timestamp
children: React.ReactNode;
onDeleteUser?: (uuid?: string) => void | Promise<void>;
onMigrateUser?: () => void;
}
export function UserPopup({
children,
username,
displayName,
public_key,
uuid,
avatar,
connected,
storage,
createdAt,
onDeleteUser,
onMigrateUser,
}: UserPopupProps) {
const [copiedUUID, setCopiedUUID] = useState(false);
const [copiedPublicKey, setCopiedPublicKey] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const uuidId = useId();
const publicKeyId = useId();
const copyToClipboard = async (text: string, type: "uuid" | "publicKey") => {
try {
await navigator.clipboard.writeText(text);
if (type === "uuid") {
setCopiedUUID(true);
setTimeout(() => setCopiedUUID(false), 2000);
} else if (type === "publicKey") {
setCopiedPublicKey(true);
setTimeout(() => setCopiedPublicKey(false), 2000);
}
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
const formatStorage = (kilobytes: number) => {
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
} else if (kilobytes < 1024 * 1024) {
return `${(kilobytes / 1024).toFixed(1)} MB`;
} else {
return `${(kilobytes / (1024 * 1024)).toFixed(1)} GB`;
}
};
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString();
};
const handleDeleteUser = async () => {
if (!onDeleteUser || !uuid) return;
setIsDeleting(true);
try {
await onDeleteUser(uuid);
// Dialog will close automatically when the component unmounts
} catch (error) {
console.error("Failed to delete user:", error);
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleDeleteClick = () => {
if (!showDeleteConfirm) {
setShowDeleteConfirm(true);
} else {
handleDeleteUser();
}
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-w-[95vw] mx-auto">
<DialogHeader className="space-y-4">
<div className="flex items-center justify-between">
<DialogTitle className="text-xl font-semibold">
User Details
</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<Avatar className="h-20 w-20 shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback
className={cn(
"text-lg font-semibold",
connected ? "bg-green-500 text-white" : "bg-gray-200",
)}
>
{displayName
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase() || "UN"}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center sm:text-left space-y-2">
<div>
<h3 className="text-lg font-semibold">
{displayName || "Unknown User"}
</h3>
<p className="text-muted-foreground">
@{username || "unknown"}
</p>
</div>
<div className="flex justify-center sm:justify-start">
<Badge variant={"secondary"} className="gap-1">
<div
className={cn(
"w-2 h-2 rounded-full",
connected ? "bg-green-400" : "bg-gray-400",
)}
/>
{connected ? "Currently Connected" : "Not Connected"}
</Badge>
</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-medium">
<HardDrive className="h-4 w-4" />
Storage Usage
</Label>
<div className="p-3 bg-muted/50 rounded-md">
<p className="font-mono text-sm">
{storage ? formatStorage(storage) : "0 B"}
</p>
</div>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-medium">
<Calendar className="h-4 w-4" />
Created
</Label>
<div className="p-3 bg-muted/50 rounded-md">
<p className="text-sm">
{createdAt ? formatDate(createdAt) : "idk man"}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label
htmlFor={uuidId}
className="flex items-center gap-2 text-sm font-medium"
>
<User className="h-4 w-4" />
User ID
</Label>
<div className="flex gap-2">
<Input
id={uuidId}
value={uuid || "N/A"}
readOnly
className="font-mono text-sm bg-muted/50"
/>
<Button
variant="outline"
size="default"
onClick={() => uuid && copyToClipboard(uuid, "uuid")}
className="shrink-0 px-3"
>
{copiedUUID ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label
htmlFor={publicKeyId}
className="flex items-center gap-2 text-sm font-medium"
>
<Key className="h-4 w-4" />
Public Key
</Label>
<div className="flex gap-2">
<Input
id={publicKeyId}
value={public_key || "N/A"}
readOnly
className="font-mono text-xs bg-muted/50"
/>
<Button
variant="outline"
size="default"
onClick={() =>
public_key && copyToClipboard(public_key, "publicKey")
}
className="shrink-0 px-3"
>
{copiedPublicKey ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<Separator />
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button
variant="secondary"
onClick={onMigrateUser}
className="flex-1 gap-2"
>
<ArrowRight className="h-4 w-4" />
Migrate to Another Iota
</Button>
{showDeleteConfirm ? (
<div className="flex gap-2 flex-1">
<Button
variant="destructive"
onClick={handleDeleteClick}
disabled={isDeleting}
className="flex-1 gap-2"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Confirm Delete
</>
)}
</Button>
</div>
) : (
<Button
variant="destructive"
onClick={handleDeleteClick}
className="flex-1 gap-2"
>
<Trash2 className="h-4 w-4" />
Delete User
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { HardDrive } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UserPopup } from "../popup";
import type { User } from "@/hooks/use-users";
interface UserGridViewProps {
users: User[];
formatBytes: (kilobytes: number) => string;
onDeleteUser?: (uuid: string) => Promise<void>;
}
export function UserGridView({
users,
formatBytes,
onDeleteUser,
}: UserGridViewProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 gap-4">
{users.map((user) => (
<UserPopup
key={user.uuid}
username={user.username}
displayName={user.displayName}
uuid={user.uuid}
avatar={user.avatar}
connected={user.connected}
storage={user.storage}
createdAt={user.createdAt}
public_key={user.public_key}
onDeleteUser={() => onDeleteUser?.(user.uuid)}
onMigrateUser={() => console.log("Migrate user:", user.uuid)}
>
<Card className="cursor-pointer hover:bg-muted/50 transition-colors duration-200 ease-out aspect-square">
<div className="p-4 h-full flex flex-col items-center justify-center text-center space-y-3">
<Avatar
className={`h-16 w-16 ${!user.connected ? "grayscale" : ""}`}
>
<AvatarImage src={user.avatar} alt={user.displayName} />
<AvatarFallback
className={`text-base ${
user.connected ? "bg-green-500 text-white" : ""
}`}
>
{user.displayName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 w-full">
<h3 className="font-medium truncate text-base leading-tight">
{user.displayName}
</h3>
<p className="text-sm text-muted-foreground truncate">
@{user.username}
</p>
</div>
<div className="flex flex-col items-center gap-2 text-sm">
<div className="flex items-center gap-1">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">
{user.storage ? formatBytes(user.storage) : "0 B"}
</span>
</div>
</div>
</div>
</Card>
</UserPopup>
))}
</div>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UserPopup } from "../popup";
import type { User } from "@/hooks/use-users";
import { Server } from "lucide-react";
interface UserListViewProps {
users: User[];
formatBytes: (kilobytes: number) => string;
onDeleteUser?: (uuid: string) => Promise<void>;
}
export function UserListView({
users,
formatBytes,
onDeleteUser,
}: UserListViewProps) {
return (
<div className="space-y-3">
{users.map((user) => (
<UserPopup
key={user.uuid}
username={user.username}
displayName={user.displayName}
uuid={user.uuid}
avatar={user.avatar}
connected={user.connected}
storage={user.storage}
createdAt={user.createdAt}
public_key={user.public_key}
onDeleteUser={() => onDeleteUser?.(user.uuid)}
onMigrateUser={() => console.log("Migrate user:", user.uuid)}
>
<Card className="cursor-pointer hover:bg-muted/50 transition-colors duration-200 ease-out">
<CardContent className="">
<div className="flex items-center space-x-4">
<Avatar
className={`h-12 w-12 ${!user.connected ? "grayscale" : ""}`}
>
<AvatarImage src={user.avatar} alt={user.displayName} />
<AvatarFallback
className={`${
user.connected ? "bg-green-500 text-white" : ""
}`}
>
{user.displayName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{user.displayName}</h3>
<p className="text-sm text-muted-foreground truncate">
@{user.username}
</p>
</div>
<div className="flex text-right text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<span className="text-sm">
{user.storage ? formatBytes(user.storage) : "0 B"}
</span>
<Server className="size-4"></Server>
</div>
</div>
</div>
</CardContent>
</Card>
</UserPopup>
))}
</div>
);
}

19
src/hooks/use-mobile.ts Normal file
View file

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

115
src/hooks/use-users.ts Normal file
View file

@ -0,0 +1,115 @@
import useSWR from "swr";
// API response interface (what we receive from the server)
type ApiUserResponse = {
uuid: string;
username: string;
displayname?: string;
public_key: string;
private_key_hash: string;
avatar?: string;
storage: number;
connected: boolean;
createdAt: number;
};
// User interface (what we use in the UI - includes computed displayName)
interface User extends ApiUserResponse {
displayName: string; // Always provided by our processing
}
// SWR fetcher function
const fetcher = (url: string) => fetch(url).then((res) => res.json());
// Custom hook for checking username availability
export const useUsernameAvailability = (username: string) => {
const shouldFetch = username && username.length >= 3;
const { data, error, isLoading } = useSWR(
shouldFetch ? `https://omega.tensamin.net/api/get/id/${username}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000, // Cache for 5 seconds
},
);
return {
isAvailable: data?.type === "error", // "error" means username is available
isTaken: data?.type === "success", // "success" means username is taken
isLoading,
error,
};
};
// Custom hook for getting users list
export const useUsers = () => {
const {
data,
error,
isLoading,
mutate: refreshUsers,
} = useSWR("/api/users/get/", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000, // Cache for 30 seconds
});
// Process the API response to ensure UI compatibility
// Check if data is an array before processing
const processedUsers = (Array.isArray(data) ? data : []).map(
(user: ApiUserResponse) => ({
...user,
displayName: user.displayname || user.username, // Always provide displayName for UI
}),
) as User[];
// Wrapper function that only resolves if refresh succeeds
const refreshUsersWithPromise = async () => {
try {
const result = await refreshUsers();
// Check if the result is valid (array of users)
if (!Array.isArray(result)) {
throw new Error("Failed to load users");
}
return result;
} catch (error) {
throw error;
}
};
// Function to delete a user
const deleteUser = async (uuid: string) => {
try {
const response = await fetch("/api/users/remove/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ uuid }),
});
if (!response.ok) {
throw new Error("Failed to delete user");
}
const result = await response.json();
// Refresh the users list after successful deletion
await refreshUsersWithPromise();
return result;
} catch (error) {
throw error;
}
};
return {
users: processedUsers,
isLoading,
error,
refreshUsers: refreshUsersWithPromise,
deleteUser,
};
};
// Export User type for other components to use
export type { User };

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}