diff --git a/bun.lock b/bun.lock
index ca8be9f..2a95d77 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,15 +5,21 @@
"name": "example",
"dependencies": {
"@convex-dev/auth": "^0.0.81",
+ "@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^10.7.0",
"@t3-oss/env-nextjs": "^0.13.8",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"convex": "^1.26.0",
"eslint-plugin-prettier": "^5.5.4",
+ "lucide-react": "^0.542.0",
"next": "15.2.3",
"next-plausible": "^3.12.4",
+ "next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"require-in-the-middle": "^7.5.2",
+ "tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.41.0",
"zod": "^4.1.5",
},
@@ -29,6 +35,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"tailwindcss": "^4",
+ "tw-animate-css": "^1.3.7",
"typescript": "^5",
},
},
@@ -512,6 +519,10 @@
"@prisma/instrumentation": ["@prisma/instrumentation@6.14.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A=="],
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
@@ -858,10 +869,14 @@
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
"clear-module": ["clear-module@4.1.2", "", { "dependencies": { "parent-module": "^2.0.0", "resolve-from": "^5.0.0" } }, "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
@@ -1254,6 +1269,8 @@
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
+ "lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
+
"magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -1300,6 +1317,8 @@
"next-plausible": ["next-plausible@3.12.4", "", { "peerDependencies": { "next": "^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 ", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg=="],
+ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+
"nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
@@ -1528,6 +1547,8 @@
"synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="],
+ "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
+
"tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
@@ -1550,6 +1571,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "tw-animate-css": ["tw-animate-css@1.3.7", "", {}, "sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A=="],
+
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..23bfe78
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/styles/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/package.json b/package.json
index 0d60cf0..a88b6f7 100644
--- a/package.json
+++ b/package.json
@@ -17,15 +17,21 @@
},
"dependencies": {
"@convex-dev/auth": "^0.0.81",
+ "@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^10.7.0",
"@t3-oss/env-nextjs": "^0.13.8",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"convex": "^1.26.0",
"eslint-plugin-prettier": "^5.5.4",
+ "lucide-react": "^0.542.0",
"next": "15.2.3",
"next-plausible": "^3.12.4",
+ "next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"require-in-the-middle": "^7.5.2",
+ "tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.41.0",
"zod": "^4.1.5"
},
@@ -41,6 +47,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"tailwindcss": "^4",
+ "tw-animate-css": "^1.3.7",
"typescript": "^5"
}
}
diff --git a/public/appicon/icon-144.png b/public/appicon/icon-144.png
new file mode 100644
index 0000000..7e3cf04
Binary files /dev/null and b/public/appicon/icon-144.png differ
diff --git a/public/appicon/icon-36.png b/public/appicon/icon-36.png
new file mode 100644
index 0000000..0dd45e0
Binary files /dev/null and b/public/appicon/icon-36.png differ
diff --git a/public/appicon/icon-48.png b/public/appicon/icon-48.png
new file mode 100644
index 0000000..6a5d8f5
Binary files /dev/null and b/public/appicon/icon-48.png differ
diff --git a/public/appicon/icon-72.png b/public/appicon/icon-72.png
new file mode 100644
index 0000000..14b105b
Binary files /dev/null and b/public/appicon/icon-72.png differ
diff --git a/public/appicon/icon-96.png b/public/appicon/icon-96.png
new file mode 100644
index 0000000..608eb3f
Binary files /dev/null and b/public/appicon/icon-96.png differ
diff --git a/public/appicon/icon.png b/public/appicon/icon.png
new file mode 100644
index 0000000..098137f
Binary files /dev/null and b/public/appicon/icon.png differ
diff --git a/public/convex.svg b/public/convex.svg
deleted file mode 100644
index 7d70c4d..0000000
--- a/public/convex.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
diff --git a/public/favicon-16.png b/public/favicon-16.png
new file mode 100644
index 0000000..0ce5dd3
Binary files /dev/null and b/public/favicon-16.png differ
diff --git a/public/favicon-32.png b/public/favicon-32.png
new file mode 100644
index 0000000..2f758f6
Binary files /dev/null and b/public/favicon-32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..d871d88
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..8c261b6
Binary files /dev/null and b/public/favicon.png differ
diff --git a/src/app/globals.css b/src/app/globals.css
deleted file mode 100644
index 5f15ed5..0000000
--- a/src/app/globals.css
+++ /dev/null
@@ -1,26 +0,0 @@
-@import "tailwindcss";
-
-:root {
- --background: #ffffff;
- --foreground: #171717;
-}
-
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
-}
-
-body {
- color: var(--foreground);
- background: var(--background);
- font-family: Arial, Helvetica, sans-serif;
-}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5399bc1..3d84d9f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,8 +1,11 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
-import './globals.css';
+import '@/styles/globals.css';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
-import ConvexClientProvider from '@/components/ConvexClientProvider';
+import { ConvexClientProvider, ThemeProvider } from '@/components/providers';
+import PlausibleProvider from 'next-plausible';
+import { generateMetadata } from '@/lib/metadata';
+
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
@@ -13,13 +16,7 @@ const geistMono = Geist_Mono({
subsets: ['latin'],
});
-export const metadata: Metadata = {
- title: 'Create Next App',
- description: 'Generated by create next app',
- icons: {
- icon: '/convex.svg',
- },
-};
+export const metadata: Metadata = generateMetadata();
export default function RootLayout({
children,
@@ -28,13 +25,26 @@ export default function RootLayout({
}>) {
return (
+
- {children}
+
+ {children}
+
+
+
);
}
diff --git a/src/app/server/inner.tsx b/src/app/server/inner.tsx
index 9f81857..5b26626 100644
--- a/src/app/server/inner.tsx
+++ b/src/app/server/inner.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Preloaded, useMutation, usePreloadedQuery } from 'convex/react';
+import { type Preloaded, useMutation, usePreloadedQuery } from 'convex/react';
import { api } from '~/convex/_generated/api';
export default function Home({
diff --git a/src/components/ConvexClientProvider.tsx b/src/components/providers/ConvexClientProvider.tsx
similarity index 80%
rename from src/components/ConvexClientProvider.tsx
rename to src/components/providers/ConvexClientProvider.tsx
index a7bfb6c..f5672c4 100644
--- a/src/components/ConvexClientProvider.tsx
+++ b/src/components/providers/ConvexClientProvider.tsx
@@ -2,15 +2,15 @@
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react';
-import { ReactNode } from 'react';
+import { type ReactNode } from 'react';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
-export default function ConvexClientProvider({
+export const ConvexClientProvider = ({
children,
}: {
children: ReactNode;
-}) {
+}) => {
return (
{children}
diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx
new file mode 100644
index 0000000..ebd12ea
--- /dev/null
+++ b/src/components/providers/ThemeProvider.tsx
@@ -0,0 +1,69 @@
+'use client';
+import { useEffect, useState, type ComponentProps } from 'react';
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+import { Moon, Sun } from 'lucide-react';
+import { useTheme } from 'next-themes';
+import { Button } from '@/components/ui';
+import { cn } from '@/lib/utils';
+
+const ThemeProvider = ({
+ children,
+ ...props
+}: ComponentProps) => {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) return null;
+ return {children};
+};
+
+type ThemeToggleProps = {
+ size?: number;
+ buttonProps?: Omit, 'onClick'>;
+};
+
+const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => {
+ const { setTheme, resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ const toggleTheme = () => {
+ if (resolvedTheme === 'dark') setTheme('light');
+ else setTheme('dark');
+ };
+
+ return (
+
+ );
+};
+
+export { ThemeProvider, ThemeToggle, type ThemeToggleProps };
diff --git a/src/components/providers/index.tsx b/src/components/providers/index.tsx
new file mode 100644
index 0000000..8ab0dcf
--- /dev/null
+++ b/src/components/providers/index.tsx
@@ -0,0 +1,2 @@
+export { ConvexClientProvider } from './ConvexClientProvider';
+export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './ThemeProvider';
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..a2df8dc
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,59 @@
+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 shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white shadow-xs 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 shadow-xs 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",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx
new file mode 100644
index 0000000..3e6a69e
--- /dev/null
+++ b/src/components/ui/index.tsx
@@ -0,0 +1 @@
+export { Button, buttonVariants } from './button';
diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts
new file mode 100644
index 0000000..cf98cfb
--- /dev/null
+++ b/src/lib/metadata.ts
@@ -0,0 +1,369 @@
+import type { Metadata } from 'next';
+import * as Sentry from '@sentry/nextjs';
+
+export const generateMetadata = (): Metadata => {
+ return {
+ title: {
+ template: '%s | Tech Tracker',
+ default: 'Tech Tracker',
+ },
+ description:
+ 'App used by COG IT employees to \
+ update their status throughout the day.',
+ applicationName: 'Tech Tracker',
+ keywords:
+ 'Tech Tracker, City of Gulfport, Information Technology, T3 Template, ' +
+ 'Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib',
+ authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
+ creator: 'Gib Brown',
+ publisher: 'Gib Brown',
+ formatDetection: {
+ email: false,
+ address: false,
+ telephone: false,
+ },
+ robots: {
+ index: true,
+ follow: true,
+ nocache: false,
+ googleBot: {
+ index: true,
+ follow: true,
+ noimageindex: false,
+ 'max-video-preview': -1,
+ 'max-image-preview': 'large',
+ 'max-snippet': -1,
+ },
+ },
+ icons: {
+ icon: [
+ { url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
+ {
+ url: '/favicon-16.png',
+ type: 'image/png',
+ sizes: '16x16',
+ },
+ {
+ url: '/favicon-32.png',
+ type: 'image/png',
+ sizes: '32x32',
+ },
+ { url: '/favicon.png', type: 'image/png', sizes: '96x96' },
+ {
+ url: '/favicon.ico',
+ type: 'image/x-icon',
+ sizes: 'any',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/favicon-16.png',
+ type: 'image/png',
+ sizes: '16x16',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/favicon-32.png',
+ type: 'image/png',
+ sizes: '32x32',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/favicon.png',
+ type: 'image/png',
+ sizes: '96x96',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-36.png',
+ type: 'image/png',
+ sizes: '36x36',
+ },
+ {
+ url: '/appicon/icon-48.png',
+ type: 'image/png',
+ sizes: '48x48',
+ },
+ {
+ url: '/appicon/icon-72.png',
+ type: 'image/png',
+ sizes: '72x72',
+ },
+ {
+ url: '/appicon/icon-96.png',
+ type: 'image/png',
+ sizes: '96x96',
+ },
+ {
+ url: '/appicon/icon-144.png',
+ type: 'image/png',
+ sizes: '144x144',
+ },
+ {
+ url: '/appicon/icon.png',
+ type: 'image/png',
+ sizes: '192x192',
+ },
+ {
+ url: '/appicon/icon-36.png',
+ type: 'image/png',
+ sizes: '36x36',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-48.png',
+ type: 'image/png',
+ sizes: '48x48',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-72.png',
+ type: 'image/png',
+ sizes: '72x72',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-96.png',
+ type: 'image/png',
+ sizes: '96x96',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-144.png',
+ type: 'image/png',
+ sizes: '144x144',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon.png',
+ type: 'image/png',
+ sizes: '192x192',
+ media: '(prefers-color-scheme: dark)',
+ },
+ ],
+ shortcut: [
+ {
+ url: '/appicon/icon-36.png',
+ type: 'image/png',
+ sizes: '36x36',
+ },
+ {
+ url: '/appicon/icon-48.png',
+ type: 'image/png',
+ sizes: '48x48',
+ },
+ {
+ url: '/appicon/icon-72.png',
+ type: 'image/png',
+ sizes: '72x72',
+ },
+ {
+ url: '/appicon/icon-96.png',
+ type: 'image/png',
+ sizes: '96x96',
+ },
+ {
+ url: '/appicon/icon-144.png',
+ type: 'image/png',
+ sizes: '144x144',
+ },
+ {
+ url: '/appicon/icon.png',
+ type: 'image/png',
+ sizes: '192x192',
+ },
+ {
+ url: '/appicon/icon-36.png',
+ type: 'image/png',
+ sizes: '36x36',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-48.png',
+ type: 'image/png',
+ sizes: '48x48',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-72.png',
+ type: 'image/png',
+ sizes: '72x72',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-96.png',
+ type: 'image/png',
+ sizes: '96x96',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon-144.png',
+ type: 'image/png',
+ sizes: '144x144',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: '/appicon/icon.png',
+ type: 'image/png',
+ sizes: '192x192',
+ media: '(prefers-color-scheme: dark)',
+ },
+ ],
+ apple: [
+ {
+ url: 'appicon/icon-57.png',
+ type: 'image/png',
+ sizes: '57x57',
+ },
+ {
+ url: 'appicon/icon-60.png',
+ type: 'image/png',
+ sizes: '60x60',
+ },
+ {
+ url: 'appicon/icon-72.png',
+ type: 'image/png',
+ sizes: '72x72',
+ },
+ {
+ url: 'appicon/icon-76.png',
+ type: 'image/png',
+ sizes: '76x76',
+ },
+ {
+ url: 'appicon/icon-114.png',
+ type: 'image/png',
+ sizes: '114x114',
+ },
+ {
+ url: 'appicon/icon-120.png',
+ type: 'image/png',
+ sizes: '120x120',
+ },
+ {
+ url: 'appicon/icon-144.png',
+ type: 'image/png',
+ sizes: '144x144',
+ },
+ {
+ url: 'appicon/icon-152.png',
+ type: 'image/png',
+ sizes: '152x152',
+ },
+ {
+ url: 'appicon/icon-180.png',
+ type: 'image/png',
+ sizes: '180x180',
+ },
+ {
+ url: 'appicon/icon.png',
+ type: 'image/png',
+ sizes: '192x192',
+ },
+ {
+ url: 'appicon/icon-57.png',
+ type: 'image/png',
+ sizes: '57x57',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-60.png',
+ type: 'image/png',
+ sizes: '60x60',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-72.png',
+ type: 'image/png',
+ sizes: '72x72',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-76.png',
+ type: 'image/png',
+ sizes: '76x76',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-114.png',
+ type: 'image/png',
+ sizes: '114x114',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-120.png',
+ type: 'image/png',
+ sizes: '120x120',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-144.png',
+ type: 'image/png',
+ sizes: '144x144',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-152.png',
+ type: 'image/png',
+ sizes: '152x152',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon-180.png',
+ type: 'image/png',
+ sizes: '180x180',
+ media: '(prefers-color-scheme: dark)',
+ },
+ {
+ url: 'appicon/icon.png',
+ type: 'image/png',
+ sizes: '192x192',
+ media: '(prefers-color-scheme: dark)',
+ },
+ ],
+ other: [
+ {
+ rel: 'apple-touch-icon-precomposed',
+ url: '/appicon/icon-precomposed.png',
+ type: 'image/png',
+ sizes: '180x180',
+ },
+ ],
+ },
+ other: {
+ ...Sentry.getTraceData(),
+ },
+ appleWebApp: {
+ title: 'Tech Tracker',
+ statusBarStyle: 'black-translucent',
+ startupImage: [
+ '/icons/apple/splash-768x1004.png',
+ {
+ url: '/icons/apple/splash-1536x2008.png',
+ media: '(device-width: 768px) and (device-height: 1024px)',
+ },
+ ],
+ },
+ verification: {
+ google: 'google',
+ yandex: 'yandex',
+ yahoo: 'yahoo',
+ },
+ category: 'technology',
+ /*
+ appLinks: {
+ ios: {
+ url: 'https://techtracker.gbrown.org/ios',
+ app_store_id: 'com.gbrown.techtracker',
+ },
+ android: {
+ package: 'https://techtracker.gbrown.org/android',
+ app_name: 'app_t3_template',
+ },
+ web: {
+ url: 'https://techtracker.gbrown.org',
+ should_fallback: true,
+ },
+ },
+ */
+ };
+};
diff --git a/src/lib/middleware/ban-suspicious-ips.ts b/src/lib/middleware/ban-suspicious-ips.ts
new file mode 100644
index 0000000..6e60c32
--- /dev/null
+++ b/src/lib/middleware/ban-suspicious-ips.ts
@@ -0,0 +1,201 @@
+import { type NextRequest, NextResponse } from 'next/server';
+
+// In-memory stores for tracking IPs (use Redis in production)
+const ipAttempts = new Map();
+const ip404Attempts = new Map();
+const bannedIPs = new Set();
+
+// Ban Arctic Wolf Explicitly
+bannedIPs.add('::ffff:10.0.1.49');
+
+// Suspicious patterns that indicate malicious activity
+const MALICIOUS_PATTERNS = [
+ // Your existing patterns
+ /web-inf/i,
+ /\.jsp/i,
+ /\.php/i,
+ /puttest/i,
+ /WEB-INF/i,
+ /\.xml$/i,
+ /perl/i,
+ /xampp/i,
+ /phpwebgallery/i,
+ /FileManager/i,
+ /standalonemanager/i,
+ /h2console/i,
+ /WebAdmin/i,
+ /login_form\.php/i,
+ /%2e/i,
+ /%u002e/i,
+ /\.%00/i,
+ /\.\./,
+ /lcgi/i,
+
+ // New patterns from your logs
+ /\/appliance\//i,
+ /bomgar/i,
+ /netburner-logo/i,
+ /\/ui\/images\//i,
+ /logon_merge/i,
+ /logon_t\.gif/i,
+ /login_top\.gif/i,
+ /theme1\/images/i,
+ /\.well-known\/acme-challenge\/.*\.jpg$/i,
+ /\.well-known\/pki-validation\/.*\.jpg$/i,
+
+ // Path traversal and system file access patterns
+ /\/etc\/passwd/i,
+ /\/etc%2fpasswd/i,
+ /\/etc%5cpasswd/i,
+ /\/\/+etc/i,
+ /\\\\+.*etc/i,
+ /%2f%2f/i,
+ /%5c%5c/i,
+ /\/\/+/,
+ /\\\\+/,
+ /%00/i,
+ /%23/i,
+
+ // Encoded path traversal attempts
+ /%2e%2e/i,
+ /%252e/i,
+ /%c0%ae/i,
+ /%c1%9c/i,
+];
+
+// Suspicious HTTP methods
+const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
+
+const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
+const MAX_ATTEMPTS = 10; // Max suspicious requests per window
+const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
+
+// 404 rate limiting settings
+const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
+const MAX_404_ATTEMPTS = 10; // Max 404s before ban
+
+const getClientIP = (request: NextRequest): string => {
+ const forwarded = request.headers.get('x-forwarded-for');
+ const realIP = request.headers.get('x-real-ip');
+ const cfConnectingIP = request.headers.get('cf-connecting-ip');
+
+ if (forwarded) return (forwarded.split(',')[0] ?? '').trim();
+ if (realIP) return realIP;
+ if (cfConnectingIP) return cfConnectingIP;
+ return request.headers.get('host') ?? 'unknown';
+};
+
+const isPathSuspicious = (pathname: string): boolean => {
+ return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
+};
+
+const isMethodSuspicious = (method: string): boolean => {
+ return SUSPICIOUS_METHODS.includes(method);
+};
+
+const updateIPAttempts = (ip: string): boolean => {
+ const now = Date.now();
+ const attempts = ipAttempts.get(ip);
+
+ if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
+ ipAttempts.set(ip, { count: 1, lastAttempt: now });
+ return false;
+ }
+
+ attempts.count++;
+ attempts.lastAttempt = now;
+
+ if (attempts.count > MAX_ATTEMPTS) {
+ bannedIPs.add(ip);
+ ipAttempts.delete(ip);
+
+ setTimeout(() => {
+ bannedIPs.delete(ip);
+ }, BAN_DURATION);
+
+ return true;
+ }
+
+ return false;
+};
+
+const update404Attempts = (ip: string): boolean => {
+ const now = Date.now();
+ const attempts = ip404Attempts.get(ip);
+
+ if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) {
+ ip404Attempts.set(ip, { count: 1, lastAttempt: now });
+ return false;
+ }
+
+ attempts.count++;
+ attempts.lastAttempt = now;
+
+ if (attempts.count > MAX_404_ATTEMPTS) {
+ bannedIPs.add(ip);
+ ip404Attempts.delete(ip);
+
+ console.log(
+ `🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`,
+ );
+
+ setTimeout(() => {
+ bannedIPs.delete(ip);
+ }, BAN_DURATION);
+
+ return true;
+ }
+
+ return false;
+};
+
+export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
+ const { pathname } = request.nextUrl;
+ const method = request.method;
+ const ip = getClientIP(request);
+
+ // Check if IP is already banned
+ if (bannedIPs.has(ip)) {
+ return new NextResponse('Access denied.', { status: 403 });
+ }
+
+ const isSuspiciousPath = isPathSuspicious(pathname);
+ const isSuspiciousMethod = isMethodSuspicious(method);
+
+ // Handle suspicious activity
+ if (isSuspiciousPath || isSuspiciousMethod) {
+ const shouldBan = updateIPAttempts(ip);
+
+ if (shouldBan) {
+ console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
+ return new NextResponse('Access denied - IP banned. Please fuck off.', {
+ status: 403,
+ });
+ }
+
+ return new NextResponse('Not Found', { status: 404 });
+ }
+
+ return null;
+};
+
+// Call this function when you detect a 404 response
+export const handle404Response = (
+ request: NextRequest,
+): NextResponse | null => {
+ const ip = getClientIP(request);
+
+ if (bannedIPs.has(ip)) {
+ return new NextResponse('Access denied.', { status: 403 });
+ }
+
+ const shouldBan = update404Attempts(ip);
+
+ if (shouldBan) {
+ return new NextResponse('Access denied - IP banned for excessive 404s.', {
+ status: 403,
+ });
+ }
+
+ return null;
+};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..e0376b2
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,20 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+};
+
+export const ccn = ({
+ context,
+ className,
+ on = '',
+ off = '',
+}: {
+ context: boolean;
+ className: string;
+ on: string;
+ off: string;
+}) => {
+ return twMerge(className, context ? on : off);
+};
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..b72aecb
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,167 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme {
+ --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --font-serif: var(--font-serif);
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+
+ --shadow-2xs: var(--shadow-2xs);
+ --shadow-xs: var(--shadow-xs);
+ --shadow-sm: var(--shadow-sm);
+ --shadow: var(--shadow);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+ --shadow-xl: var(--shadow-xl);
+ --shadow-2xl: var(--shadow-2xl);
+}
+
+:root {
+ --background: oklch(0.9227 0.0011 17.1793);
+ --foreground: oklch(0.2840 0.0220 262.4967);
+ --card: oklch(0.9699 0.0013 106.4238);
+ --card-foreground: oklch(0.2840 0.0220 262.4967);
+ --popover: oklch(0.9699 0.0013 106.4238);
+ --popover-foreground: oklch(0.2840 0.0220 262.4967);
+ --primary: oklch(0.6378 0.1247 281.2150);
+ --primary-foreground: oklch(1.0000 0 0);
+ --secondary: oklch(0.8682 0.0026 48.7143);
+ --secondary-foreground: oklch(0.4507 0.0152 255.5845);
+ --muted: oklch(0.9227 0.0011 17.1793);
+ --muted-foreground: oklch(0.5551 0.0147 266.6154);
+ --accent: oklch(0.9409 0.0164 322.6966);
+ --accent-foreground: oklch(0.3774 0.0189 260.6754);
+ --destructive: oklch(0.6322 0.1310 21.4751);
+ --destructive-foreground: oklch(1.0000 0 0);
+ --border: oklch(0.8682 0.0026 48.7143);
+ --input: oklch(0.8682 0.0026 48.7143);
+ --ring: oklch(0.6378 0.1247 281.2150);
+ --chart-1: oklch(0.6378 0.1247 281.2150);
+ --chart-2: oklch(0.5608 0.1433 283.1275);
+ --chart-3: oklch(0.5008 0.1358 283.9499);
+ --chart-4: oklch(0.4372 0.1108 283.4322);
+ --chart-5: oklch(0.3928 0.0817 282.8932);
+ --sidebar: oklch(0.8682 0.0026 48.7143);
+ --sidebar-foreground: oklch(0.2840 0.0220 262.4967);
+ --sidebar-primary: oklch(0.6378 0.1247 281.2150);
+ --sidebar-primary-foreground: oklch(1.0000 0 0);
+ --sidebar-accent: oklch(0.9409 0.0164 322.6966);
+ --sidebar-accent-foreground: oklch(0.3774 0.0189 260.6754);
+ --sidebar-border: oklch(0.8682 0.0026 48.7143);
+ --sidebar-ring: oklch(0.6378 0.1247 281.2150);
+ --font-sans: Inter, sans-serif;
+ --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ --radius: 1.0rem;
+ --shadow-2xs: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.09);
+ --shadow-xs: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.09);
+ --shadow-sm: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 1px 2px 3px hsl(240 1.9608% 60% / 0.18);
+ --shadow: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 1px 2px 3px hsl(240 1.9608% 60% / 0.18);
+ --shadow-md: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 2px 4px 3px hsl(240 1.9608% 60% / 0.18);
+ --shadow-lg: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 4px 6px 3px hsl(240 1.9608% 60% / 0.18);
+ --shadow-xl: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 8px 10px 3px hsl(240 1.9608% 60% / 0.18);
+ --shadow-2xl: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.45);
+ --tracking-normal: 0em;
+ --spacing: 0.25rem;
+}
+
+.dark {
+ --background: oklch(0.2236 0.0049 67.5717);
+ --foreground: oklch(0.9301 0.0075 260.7315);
+ --card: oklch(0.2793 0.0057 56.1503);
+ --card-foreground: oklch(0.9301 0.0075 260.7315);
+ --popover: oklch(0.2793 0.0057 56.1503);
+ --popover-foreground: oklch(0.9301 0.0075 260.7315);
+ --primary: oklch(0.7223 0.0946 279.6746);
+ --primary-foreground: oklch(0.2236 0.0049 67.5717);
+ --secondary: oklch(0.3352 0.0055 56.2080);
+ --secondary-foreground: oklch(0.8726 0.0059 264.5296);
+ --muted: oklch(0.2793 0.0057 56.1503);
+ --muted-foreground: oklch(0.7176 0.0111 261.7826);
+ --accent: oklch(0.3889 0.0053 56.2463);
+ --accent-foreground: oklch(0.8726 0.0059 264.5296);
+ --destructive: oklch(0.6322 0.1310 21.4751);
+ --destructive-foreground: oklch(0.2236 0.0049 67.5717);
+ --border: oklch(0.3352 0.0055 56.2080);
+ --input: oklch(0.3352 0.0055 56.2080);
+ --ring: oklch(0.7223 0.0946 279.6746);
+ --chart-1: oklch(0.7223 0.0946 279.6746);
+ --chart-2: oklch(0.6378 0.1247 281.2150);
+ --chart-3: oklch(0.5608 0.1433 283.1275);
+ --chart-4: oklch(0.5008 0.1358 283.9499);
+ --chart-5: oklch(0.4372 0.1108 283.4322);
+ --sidebar: oklch(0.3352 0.0055 56.2080);
+ --sidebar-foreground: oklch(0.9301 0.0075 260.7315);
+ --sidebar-primary: oklch(0.7223 0.0946 279.6746);
+ --sidebar-primary-foreground: oklch(0.2236 0.0049 67.5717);
+ --sidebar-accent: oklch(0.3889 0.0053 56.2463);
+ --sidebar-accent-foreground: oklch(0.8726 0.0059 264.5296);
+ --sidebar-border: oklch(0.3352 0.0055 56.2080);
+ --sidebar-ring: oklch(0.7223 0.0946 279.6746);
+ --font-sans: Inter, sans-serif;
+ --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ --radius: 1.0rem;
+ --shadow-2xs: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.09);
+ --shadow-xs: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.09);
+ --shadow-sm: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 1px 2px 3px hsl(0 0% 10.1961% / 0.18);
+ --shadow: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 1px 2px 3px hsl(0 0% 10.1961% / 0.18);
+ --shadow-md: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 2px 4px 3px hsl(0 0% 10.1961% / 0.18);
+ --shadow-lg: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 4px 6px 3px hsl(0 0% 10.1961% / 0.18);
+ --shadow-xl: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 8px 10px 3px hsl(0 0% 10.1961% / 0.18);
+ --shadow-2xl: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.45);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}