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; + } +}