merge frontend website into scripts repo

This commit is contained in:
Bram Suurd 2024-11-04 23:55:08 +01:00
parent 103e2bea08
commit 56837d7dcd
72 changed files with 11679 additions and 0 deletions

83
.github/workflows/nextjs.yml vendored Normal file
View File

@ -0,0 +1,83 @@
# Sample workflow for building and deploying a Next.js site to GitHub Pages
#
# To get started with Next.js see: https://nextjs.org/docs/getting-started
#
name: Deploy Next.js site to Pages
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend # Set default working directory for all run steps
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect package manager
id: detect-package-manager
run: |
if [ -f "${{ github.workspace }}/frontend/yarn.lock" ]; then
echo "manager=yarn" >> $GITHUB_OUTPUT
echo "command=install" >> $GITHUB_OUTPUT
echo "runner=yarn" >> $GITHUB_OUTPUT
exit 0
elif [ -f "${{ github.workspace }}/frontend/package.json" ]; then
echo "manager=npm" >> $GITHUB_OUTPUT
echo "command=ci" >> $GITHUB_OUTPUT
echo "runner=npx --no-install" >> $GITHUB_OUTPUT
exit 0
else
echo "Unable to determine package manager"
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: ${{ steps.detect-package-manager.outputs.manager }}
cache-dependency-path: frontend/package-lock.json # Specify the path to package-lock.json
- name: Setup Pages
uses: actions/configure-pages@v5
with:
static_site_generator: next
- name: Restore cache
uses: actions/cache@v4
with:
path: |
frontend/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('frontend/**/package-lock.json', 'frontend/**/yarn.lock') }}-${{ hashFiles('frontend/**.[jt]s', 'frontend/**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('frontend/**/package-lock.json', 'frontend/**/yarn.lock') }}-
- name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} --legacy-peer-deps
- name: Build with Next.js
run: ${{ steps.detect-package-manager.outputs.runner }} next build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: frontend/out
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

3
frontend/.env.local Normal file
View File

@ -0,0 +1,3 @@
NEXT_PUBLIC_ANALYTICS_TOKEN="b60d3032-1a11-4244-a100-81d26c5c49a7"
NEXT_PUBLIC_ANALYTICS_URL="analytics.proxmoxve-scripts.com"
NEXT_PUBLIC_POCKETBASE_URL="https://pocketbase.proxmoxve-scripts.com"

5
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": ["next/core-web-vitals"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"]
}

39
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# wrangler
.worker-next
.wrangler
# testing
/coverage
# next.js
/.next/
out
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# # local env files
# .env*.local
# .env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
frontend/.prettierignore Normal file
View File

@ -0,0 +1,5 @@
dist
node_modules
.next
build
.contentlayer

3
frontend/.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-organize-imports"]
}

21
frontend/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Bram Suurd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

17
frontend/components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "@/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

4
frontend/example.env Normal file
View File

@ -0,0 +1,4 @@
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.proxmoxve-scripts.com
NEXT_PUBLIC_ANALYTICS_URL=https://analytics.proxmoxve-scripts.com
NEXT_PUBLIC_ANALYTICS_TOKEN=b60d130323-1a11-4244-a1010-81d263c5c49a7
NODE_ENV=production

21
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.resolve.alias.canvas = false;
return config;
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
output: "export",
// basePath: "/proxmox-helper-scripts",
};
export default nextConfig;

7546
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
frontend/package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "proxmox-helper-scripts-website",
"version": "1.0.0",
"license": "MIT",
"private": true,
"author": {
"name": "Bram Suurd",
"url": "https://github.com/community-scripts"
},
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"deploy": "next build && touch out/.nojekyll && git add out/ && git commit -m \"Deploy\" && git subtree push --prefix out origin gh-pages",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@vercel/analytics": "^1.2.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"framer-motion": "^11.11.10",
"fuse.js": "^7.0.0",
"lucide-react": "^0.453.0",
"mini-svg-data-uri": "^1.4.4",
"next": "15.0.2",
"next-themes": "^0.3.0",
"nuqs": "^2.1.1",
"pocketbase": "^0.21.4",
"prettier-plugin-organize-imports": "^4.1.0",
"react": "19.0.0-rc-02c0e824-20241028",
"react-code-blocks": "^0.1.6",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"react-icons": "^5.1.0",
"react-simple-typewriter": "^5.0.1",
"sharp": "^0.33.5",
"simple-icons": "^13.5.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@typescript-eslint/eslint-plugin": "^8.8.1",
"@typescript-eslint/parser": "^8.8.1",
"eslint-config-next": "15.0.2",
"postcss": "^8",
"eslint": "^9.13.0",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-animated": "^1.1.2",
"typescript": "^5"
},
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -0,0 +1,23 @@
{
"slug": "docker",
"logo": "https://raw.githubusercontent.com/loganmarchione/homelab-svg-assets/main/assets/docker.svg",
"description": "Docker is an open-source project for automating the deployment of applications as portable, self-sufficient containers.",
"date_created": "2024-05-02",
"website": "https://www.docker.com/",
"documentation": "",
"default_credentials": {
"username": "",
"password": ""
},
"alerts": [
{
"alert": "If the LXC is created Privileged, the script will automatically set up USB passthrough."
},
{
"alert": "Run Compose V2 by replacing the hyphen (-) with a space, using `docker compose`, instead of `docker-compose`."
},
{
"alert": "Options to Install Portainer and/or Docker Compose V2"
}
]
}

View File

@ -0,0 +1,20 @@
{
"slug": "nginxproxymanager",
"logo": "https://raw.githubusercontent.com/loganmarchione/homelab-svg-assets/main/assets/nginxproxymanager.svg",
"description": "Nginx Proxy Manager is a tool that provides a web-based interface to manage Nginx reverse proxies. It enables users to easily and securely expose their services to the internet by providing features such as HTTPS encryption, domain mapping, and access control. It eliminates the need for manual configuration of Nginx reverse proxies, making it easy for users to quickly and securely expose their services to the public.",
"date_created": "2024-05-02",
"website": "https://nginxproxymanager.com/",
"documentation": "",
"default_credentials": {
"username": "admin",
"password": "admin"
},
"alerts": [
{
"alert": "Since there are hundreds of Certbot instances, it's necessary to install the specific Certbot of your preference."
},
{
"alert": "This is another example of an alert."
}
]
}

View File

@ -0,0 +1,23 @@
import { pb } from "@/lib/pocketbase";
import { Category } from "@/lib/types";
import { NextResponse } from "next/server";
export const dynamic = "force-static";
export async function GET() {
try {
const response = await pb.collection("categories").getFullList<Category>({
expand: "items.alerts,items.alpine_script,items.default_login",
sort: "order",
});
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching categories:", error);
return NextResponse.json(
{ error: "Failed to fetch categories" },
{ status: 500 },
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,88 @@
import Footer from "@/components/Footer";
import Navbar from "@/components/Navbar";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import "@/styles/globals.css";
import { Inter } from "next/font/google";
import React from "react";
import { NuqsAdapter } from "nuqs/adapters/next/app";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Proxmox VE Helper-Scripts",
generator: "Next.js",
applicationName: "Proxmox VE Helper-Scripts",
referrer: "origin-when-cross-origin",
keywords: [
"Proxmox VE",
"Helper-Scripts",
"tteck",
"helper",
"scripts",
"proxmox",
"VE",
],
authors: { name: "Bram Suurd" },
creator: "Bram Suurd",
publisher: "Bram Suurd",
description:
"A Front-end for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 200+ scripts to help you manage your Proxmox VE environment.",
favicon: "/app/favicon.ico",
formatDetection: {
email: false,
address: false,
telephone: false,
},
metadataBase: new URL("https://community-scripts.github.io/Proxmox/"),
openGraph: {
title: "Proxmox VE Helper-Scripts",
description:
"A Front-end for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 200+ scripts to help you manage your Proxmox VE environment.",
url: "/defaultimg.png",
images: [
{
url: "https://community-scripts.github.io/Proxmox/defaultimg.png",
},
],
locale: "en_US",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
defer
src={`https://${process.env.NEXT_PUBLIC_ANALYTICS_URL}/script.js`}
data-website-id={process.env.NEXT_PUBLIC_ANALYTICS_TOKEN}
></script>
<link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href={process.env.NEXT_PUBLIC_POCKETBASE_URL} />
<link rel="preconnect" href="https://api.github.com" />
</head>
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<div className="flex w-full flex-col justify-center">
<Navbar />
<div className="flex min-h-screen flex-col justify-center">
<div className="flex w-full justify-center">
<div className="w-full max-w-7xl ">
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster richColors />
</div>
</div>
<Footer />
</div>
</div>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,27 @@
import type { MetadataRoute } from "next";
export const generateStaticParams = () => {
return [];
};
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Proxmox VE Helper-Scripts",
short_name: "Proxmox VE Helper-Scripts",
description:
"A Re-designed Front-end for the Proxmox VE Helper-Scripts Repository. Featuring over 150+ scripts to help you manage your Proxmox VE environment.",
theme_color: "#030712",
background_color: "#030712",
display: "standalone",
orientation: "portrait",
scope: "/Proxmox/",
start_url: "/Proxmox/",
icons: [
{
src: "logo.png",
sizes: "512x512",
type: "image/png",
},
],
};
}

View File

@ -0,0 +1,20 @@
"use client";
import { Button } from "@/components/ui/button";
export default function NotFoundPage() {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-5 bg-background px-4 md:px-6">
<div className="space-y-2 text-center">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
404
</h1>
<p className="text-muted-foreground md:text-xl">
Oops, the page you are looking for could not be found.
</p>
</div>
<Button onClick={() => window.history.back()} variant="secondary">
Go Back
</Button>
</div>
);
}

123
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,123 @@
"use client";
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
import Particles from "@/components/ui/particles";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { ArrowRightIcon, ExternalLink } from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { CardFooter } from "@/components/ui/card";
import { FaGithub } from "react-icons/fa";
function CustomArrowRightIcon() {
return <ArrowRightIcon className="h-4 w-4" width={1} />;
}
export default function Page() {
const { theme } = useTheme();
const [color, setColor] = useState("#000000");
useEffect(() => {
setColor(theme === "dark" ? "#ffffff" : "#000000");
}, [theme]);
return (
<div className="w-full">
<Particles
className="absolute inset-0 -z-40"
quantity={100}
ease={80}
color={color}
refresh
/>
<div className="container mx-auto">
<div className="flex h-[80vh] flex-col items-center justify-center gap-4 py-20 lg:py-40">
<Dialog>
<DialogTrigger>
<div>
<AnimatedGradientText>
<div
className={cn(
`absolute inset-0 block size-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] [border-radius:inherit] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`,
`p-px ![mask-composite:subtract]`,
)}
/>
<Separator className="mx-2 h-4" orientation="vertical" />
<span
className={cn(
`animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
`inline`,
)}
>
Scripts by Tteck
</span>
</AnimatedGradientText>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Thank You!</DialogTitle>
<DialogDescription>
A big thank you to Tteck and the many contributors who have
made this project possible. Your hard work is truly
appreciated by the entire Proxmox community!
</DialogDescription>
</DialogHeader>
<CardFooter className="flex flex-col gap-2">
<Button className="w-full" variant="outline" asChild>
<a
href="https://github.com/tteck"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<FaGithub className="mr-2 h-4 w-4" /> Tteck&apos;s GitHub
</a>
</Button>
<Button className="w-full" asChild>
<a
href="https://github.com/community-scripts/ProxmoxVE"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper
Scripts
</a>
</Button>
</CardFooter>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-4">
<h1 className="max-w-2xl text-center text-5xl font-semibold tracking-tighter md:text-7xl">
Make managing your Homelab a breeze
</h1>
<p className="max-w-2xl text-center text-lg leading-relaxed tracking-tight text-muted-foreground md:text-xl">
200+ scripts to help you manage your <b>Proxmox VE environment</b>
. Whether you&apos;re a seasoned user or a newcomer, Proxmox VE
Helper Scripts has got you covered.
</p>
</div>
<div className="flex flex-row gap-3">
<Link href="/scripts">
<Button
size="lg"
variant="expandIcon"
Icon={CustomArrowRightIcon}
iconPlacement="right"
className="hover:"
>
View Scripts
</Button>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://community-scripts.github.io/Proxmox/sitemap.xml",
};
}

View File

@ -0,0 +1,147 @@
import { useCallback, useEffect, useRef } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import { Star } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "../../../components/ui/badge";
export default function ScriptAccordion({
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
}) {
const [expandedItem, setExpandedItem] = useState<string | undefined>(
undefined,
);
const linkRefs = useRef<{ [key: string]: HTMLAnchorElement | null }>({});
const handleAccordionChange = (value: string | undefined) => {
setExpandedItem(value);
};
const handleSelected = useCallback(
(title: string) => {
setSelectedScript(title);
},
[setSelectedScript],
);
useEffect(() => {
if (selectedScript) {
const category = items.find((category) =>
category.expand.items.some((script) => script.title === selectedScript),
);
if (category) {
setExpandedItem(category.catagoryName);
handleSelected(selectedScript);
}
}
}, [selectedScript, items, handleSelected]);
return (
<Accordion
type="single"
value={expandedItem}
onValueChange={handleAccordionChange}
collapsible
>
{items.map((category) => (
<AccordionItem
key={category.id + ":category"}
value={category.catagoryName}
className={cn("sm:text-md flex flex-col border-none", {
"rounded-lg bg-accent/30": expandedItem === category.catagoryName,
})}
>
<AccordionTrigger
className={cn(
"duration-250 rounded-lg transition ease-in-out hover:-translate-y-1 hover:scale-105 hover:bg-accent",
{ "": expandedItem === category.catagoryName },
)}
>
<div className="mr-2 flex w-full items-center justify-between">
<span className="pl-2">{category.catagoryName} </span>
<span className="rounded-full bg-gray-200 px-2 py-1 text-xs text-muted-foreground hover:no-underline dark:bg-blue-800/20">
{category.expand.items.length}
</span>
</div>{" "}
</AccordionTrigger>
<AccordionContent
data-state={
expandedItem === category.catagoryName ? "open" : "closed"
}
className="pt-0"
>
{category.expand.items
.slice()
.sort((a, b) => a.title.localeCompare(b.title))
.map((script, index) => (
<div key={index}>
<Link
href={{
pathname: "/scripts",
query: { id: script.title },
}}
prefetch={false}
className={`flex cursor-pointer items-center justify-between gap-1 px-1 py-1 text-muted-foreground hover:rounded-lg hover:bg-accent/60 hover:dark:bg-accent/20 ${
selectedScript === script.title
? "rounded-lg bg-accent font-semibold dark:bg-accent/30 dark:text-white"
: ""
}`}
onClick={() => handleSelected(script.title)}
ref={(el) => {
linkRefs.current[script.title] = el;
}}
>
<Image
src={script.logo}
height={16}
width={16}
unoptimized
onError={(e) =>
((e.currentTarget as HTMLImageElement).src =
"/logo.png")
}
alt={script.title}
className="mr-1 w-4 h-4 rounded-full"
/>
<span className="flex items-center gap-2">
{script.title}
{script.isMostViewed && (
<Star className="h-3 w-3 text-yellow-500"></Star>
)}
</span>
<Badge
className={cn(
"ml-auto w-[37.69px] justify-center text-center",
{
"text-primary/75": script.item_type === "VM",
"text-yellow-500/75": script.item_type === "LXC",
"border-none": script.item_type === "",
hidden: !["VM", "LXC", ""].includes(script.item_type),
},
)}
>
{script.item_type}
</Badge>
</Link>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
}

View File

@ -0,0 +1,219 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { extractDate } from "@/lib/time";
import { Category } from "@/lib/types";
import { CalendarPlus } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
const ITEMS_PER_PAGE = 3;
export function LatestScripts({ items }: { items: Category[] }) {
const [page, setPage] = useState(1);
const latestScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.expand.items || []);
return scripts.sort(
(a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(),
);
}, [items]);
const goToNextPage = () => {
setPage((prevPage) => prevPage + 1);
};
const goToPreviousPage = () => {
setPage((prevPage) => prevPage - 1);
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
if (!items) {
return null;
}
return (
<div className="">
{latestScripts.length > 0 && (
<div className="flex w-full items-center justify-between">
<h2 className="text-lg font-semibold">Newest Scripts</h2>
<div className="flex items-center justify-end gap-1">
{page > 1 && (
<div
className="cursor-pointer select-none p-2 text-sm font-semibold"
onClick={goToPreviousPage}
>
Previous
</div>
)}
{endIndex < latestScripts.length && (
<div
onClick={goToNextPage}
className="cursor-pointer select-none p-2 text-sm font-semibold"
>
{page === 1 ? "More.." : "Next"}
</div>
)}
</div>
</div>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{latestScripts.slice(startIndex, endIndex).map((item) => (
<Card
key={item.id}
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-accent p-1">
<Image
src={item.logo}
unoptimized
height={64}
width={64}
alt=""
className="h-11 w-11 object-contain"
/>
</div>
<div className="flex flex-col">
<p className="text-lg line-clamp-1">
{item.title} {item.item_type}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarPlus className="h-4 w-4" />
{extractDate(item.created)}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground">
{item.description}
</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
<Link
href={{
pathname: "/scripts",
query: { id: item.title },
}}
>
View Script
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
}
export function MostViewedScripts({ items }: { items: Category[] }) {
const [page, setPage] = useState(1);
const mostViewedScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.expand.items || []);
const mostViewedScripts = scripts
.filter((script) => script.isMostViewed)
.map((script) => ({
...script,
}));
return mostViewedScripts;
}, [items]);
const goToNextPage = () => {
setPage((prevPage) => prevPage + 1);
};
const goToPreviousPage = () => {
setPage((prevPage) => prevPage - 1);
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
return (
<div className="">
{mostViewedScripts.length > 0 && (
<>
<h2 className="text-lg font-semibold">Most Viewed Scripts</h2>
</>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{mostViewedScripts.slice(startIndex, endIndex).map((item) => (
<Card
key={item.id}
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="flex max-h-16 min-h-16 min-w-16 max-w-16 items-center justify-center rounded-lg bg-accent p-1">
<Image
unoptimized
src={item.logo}
height={64}
width={64}
alt=""
className="h-11 w-11 object-contain"
/>
</div>
<div className="flex flex-col">
<p className="line-clamp-1 text-lg">
{item.title} {item.item_type}
</p>
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarPlus className="h-4 w-4" />
{extractDate(item.created)}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground break-words">
{item.description}
</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
<Link
href={{
pathname: "/scripts",
query: { id: item.title },
}}
prefetch={false}
>
View Script
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
<div className="flex justify-end gap-1 p-2">
{page > 1 && (
<Button onClick={goToPreviousPage} variant="outline">
Previous
</Button>
)}
{endIndex < mostViewedScripts.length && (
<Button onClick={goToNextPage} variant="outline">
{page === 1 ? "More.." : "Next"}
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { Separator } from "@/components/ui/separator";
import { extractDate } from "@/lib/time";
import { Script } from "@/lib/types";
import { X } from "lucide-react";
import Image from "next/image";
import Alerts from "./ScriptItems/Alerts";
import Buttons from "./ScriptItems/Buttons";
import DefaultPassword from "./ScriptItems/DefaultPassword";
import DefaultSettings from "./ScriptItems/DefaultSettings";
import Description from "./ScriptItems/Description";
import InstallCommand from "./ScriptItems/InstallCommand";
import InterFaces from "./ScriptItems/InterFaces";
import Tooltips from "./ScriptItems/Tooltips";
function ScriptItem({
item,
setSelectedScript,
}: {
item: Script;
setSelectedScript: (script: string | null) => void;
}) {
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
return (
<div className="mr-7 mt-0 flex w-full min-w-fit">
<div className="flex w-full min-w-fit">
<div className="flex w-full flex-col">
<div className="flex h-[36px] min-w-max items-center justify-between">
<h2 className="text-lg font-semibold">Selected Script</h2>
<X onClick={closeScript} className="cursor-pointer" />
</div>
<div className="rounded-lg border bg-accent/20 p-4">
<div className="flex justify-between">
<div className="flex">
<Image
className="h-32 w-32 rounded-lg bg-accent/60 object-contain p-3 shadow-md"
src={item.logo}
width={400}
onError={(e) =>
((e.currentTarget as HTMLImageElement).src = "/logo.png")
}
height={400}
alt={item.title}
unoptimized
/>
<div className="ml-4 flex flex-col justify-between">
<div className="flex h-full w-full flex-col justify-between">
<div>
<h1 className="text-lg font-semibold">{item.title}</h1>
<p className="w-full text-sm text-muted-foreground">
Date added: {extractDate(item.created)}
</p>
</div>
<div className="flex gap-5">
<DefaultSettings item={item} />
</div>
</div>
</div>
</div>
<div className="hidden flex-col justify-between gap-2 sm:flex">
<InterFaces item={item} />
<Buttons item={item} />
</div>
</div>
<Separator className="mt-4" />
<div>
<div className="mt-4">
<Description item={item} />
<Alerts item={item} />
</div>
<div className="mt-4 rounded-lg border bg-accent/50">
<div className="flex gap-3 px-4 py-2">
<h2 className="text-lg font-semibold">
How to {item.item_type ? "install" : "use"}
</h2>
<Tooltips item={item} />
</div>
<Separator className="w-full"></Separator>
<InstallCommand item={item} />
</div>
</div>
<DefaultPassword item={item} />
</div>
</div>
</div>
</div>
);
}
export default ScriptItem;

View File

@ -0,0 +1,19 @@
import TextCopyBlock from "@/lib/TextCopyBlock";
import { Script } from "@/lib/types";
import { Info } from "lucide-react";
export default function Alerts({ item }: { item: Script }) {
return (
<>
{item.expand?.alerts?.length > 0 &&
item.expand.alerts.map((alert: any, index: number) => (
<div key={index} className="mt-4 flex flex-col gap-2">
<p className="inline-flex items-center gap-2 rounded-lg border border-red-500/25 bg-destructive/25 p-2 pl-4 text-sm">
<Info className="h-4 min-h-4 w-4 min-w-4" />
<span>{TextCopyBlock(alert.content)}</span>
</p>
</div>
))}
</>
);
}

View File

@ -0,0 +1,74 @@
import { Button } from "@/components/ui/button";
import { Script } from "@/lib/types";
import { BookOpenText, Code, ExternalLink, Globe } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
export default function Buttons({ item }: { item: Script }) {
const pattern = useMemo(
() =>
/(https:\/\/github\.com\/community-scripts\/ProxmoxVE\/raw\/main\/(ct|misc|vm)\/([^\/]+)\.sh)/,
[],
);
const transformUrlToInstallScript = (url: string): string => {
if (url.includes("/pve/")) {
return url;
} else if (url.includes("/ct/")) {
return url.replace("/ct/", "/install/").replace(/\.sh$/, "-install.sh");
}
return url;
};
const sourceUrl = useMemo(() => {
if (item.installCommand) {
const match = item.installCommand.match(pattern);
return match ? transformUrlToInstallScript(match[0]) : null;
}
return null;
}, [item.installCommand, pattern]);
return (
<div className="flex flex-wrap justify-end gap-2">
{item.website && (
<Button variant="secondary" asChild>
<Link target="_blank" href={item.website}>
<span className="flex items-center gap-2">
<Globe className="h-4 w-4" /> Website
</span>
</Link>
</Button>
)}
{item.documentation && (
<Button variant="secondary" asChild>
<Link target="_blank" href={item.documentation}>
<span className="flex items-center gap-2">
<BookOpenText className="h-4 w-4" />
Documentation
</span>
</Link>
</Button>
)}
{item.post_install && (
<Button variant="secondary" asChild>
<Link target="_blank" href={item.post_install}>
<span className="flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
Post Install
</span>
</Link>
</Button>
)}
{item.installCommand && sourceUrl && (
<Button variant="secondary" asChild>
<Link target="_blank" href={transformUrlToInstallScript(sourceUrl)}>
<span className="flex items-center gap-2">
<Code className="h-4 w-4" />
Source Code
</span>
</Link>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,51 @@
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import handleCopy from "@/components/handleCopy";
import { Script } from "@/lib/types";
export default function DefaultPassword({ item }: { item: Script }) {
const hasDefaultLogin = item?.expand?.default_login !== undefined;
return (
<div>
{hasDefaultLogin && (
<div className="mt-4 rounded-lg border bg-accent/50">
<div className="flex gap-3 px-4 py-2">
<h2 className="text-lg font-semibold">Default Login Credentials</h2>
</div>
<Separator className="w-full"></Separator>
<div className="flex flex-col gap-2 p-4">
<p className="mb-2 text-sm">
You can use the following credentials to login to the {""}
{item.title} {item.item_type}.
</p>
<div className="text-sm">
Username:{" "}
<Button
variant={"secondary"}
size={"null"}
onClick={() =>
handleCopy("username", item.expand.default_login.username)
}
>
{item.expand.default_login.username}
</Button>
</div>
<div className="text-sm">
Password:{" "}
<Button
variant={"secondary"}
size={"null"}
onClick={() =>
handleCopy("password", item.expand.default_login.password)
}
>
{item.expand.default_login.password}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { Script } from "@/lib/types";
export default function DefaultSettings({ item }: { item: Script }) {
const hasAlpineScript = item?.expand?.alpine_script !== undefined;
return (
<>
{item.default_cpu && (
<div>
<h2 className="text-md font-semibold">Default settings</h2>
<p className="text-sm text-muted-foreground">
CPU: {item.default_cpu}
</p>
<p className="text-sm text-muted-foreground">
RAM: {item.default_ram}
</p>
<p className="text-sm text-muted-foreground">
HDD: {item.default_hdd}
</p>
</div>
)}
{hasAlpineScript && (
<div>
<h2 className="text-md font-semibold">Default Alpine settings</h2>
<p className="text-sm text-muted-foreground">
CPU: {item.expand.alpine_script.default_cpu}
</p>
<p className="text-sm text-muted-foreground">
RAM: {item.expand.alpine_script.default_ram}
</p>
<p className="text-sm text-muted-foreground">
HDD: {item.expand.alpine_script.default_hdd}
</p>
</div>
)}
</>
);
}

View File

@ -0,0 +1,13 @@
import TextCopyBlock from "@/lib/TextCopyBlock";
import { Script } from "@/lib/types";
export default function Description({ item }: { item: Script }) {
return (
<div className="p-2">
<h2 className="mb-2 max-w-prose text-lg font-semibold">Description</h2>
<p className="text-sm text-muted-foreground">
{TextCopyBlock(item.description)}
</p>
</div>
);
}

View File

@ -0,0 +1,68 @@
import CodeCopyButton from "@/components/ui/code-copy-button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Script } from "@/lib/types";
export default function InstallCommand({ item }: { item: Script }) {
const { title, item_type, installCommand, expand } = item;
const hasAlpineScript = expand?.alpine_script !== undefined;
const renderInstructions = (isAlpine = false) => (
<>
<p className="text-sm mt-2">
{isAlpine ? (
<>
As an alternative option, you can use Alpine Linux and the {title}{" "}
package to create a {title} {item_type} container with faster
creation time and minimal system resource usage. You are also
obliged to adhere to updates provided by the package maintainer.
</>
) : item_type ? (
<>
To create a new Proxmox VE {title} {item_type}, run the command
below in the Proxmox VE Shell.
</>
) : (
<>To use the {title} script, run the command below in the shell.</>
)}
</p>
{isAlpine && (
<p className="mt-2 text-sm">
To create a new Proxmox VE Alpine-{title} {item_type}, run the command
below in the Proxmox VE Shell
</p>
)}
</>
);
return (
<div className="p-4">
{hasAlpineScript ? (
<Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
<TabsList>
<TabsTrigger value="default">Default</TabsTrigger>
<TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
</TabsList>
<TabsContent value="default">
{renderInstructions()}
<CodeCopyButton>{installCommand}</CodeCopyButton>
</TabsContent>
<TabsContent value="alpine">
{expand.alpine_script && (
<>
{renderInstructions(true)}
<CodeCopyButton>
{expand.alpine_script.installCommand}
</CodeCopyButton>
</>
)}
</TabsContent>
</Tabs>
) : (
<>
{renderInstructions()}
{installCommand && <CodeCopyButton>{installCommand}</CodeCopyButton>}
</>
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Button, buttonVariants } from "@/components/ui/button";
import handleCopy from "@/components/handleCopy";
import { cn } from "@/lib/utils";
import { ClipboardIcon } from "lucide-react";
interface Item {
interface?: string;
port?: number;
}
const CopyButton = ({
label,
value,
}: {
label: string;
value: string | number;
}) => (
<span className={cn(buttonVariants({size: "sm", variant: "secondary"}), "flex items-center gap-2")}>
{value}
<ClipboardIcon
onClick={() => handleCopy(label, String(value))}
className="size-4 cursor-pointer"
/>
</span>
);
export default function InterFaces({ item }: { item: Item }) {
const { interface: iface, port } = item;
return (
<div className="flex flex-col gap-2">
{iface || (port && port !== 0) ? (
<div className="flex items-center justify-end">
<h2 className="mr-2 text-end text-lg font-semibold">
{iface ? "Interface:" : "Default Port:"}
</h2>{" "}
<CopyButton
label={iface ? "interface" : "port"}
value={iface || port!}
/>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,49 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Script } from "@/lib/types";
import React from "react";
interface TooltipProps {
variant: "warning" | "success";
label: string;
content: string;
}
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger className="flex items-center">
<Badge variant={variant}>{label}</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-sm">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export default function Tooltips({ item }: { item: Script }) {
return (
<div className="flex items-center gap-2">
{item.privileged && (
<TooltipBadge
variant="warning"
label="Privileged"
content="This script will be run in a privileged LXC"
/>
)}
{item.isUpdateable && (
<TooltipBadge
variant="success"
label="Updateable"
content={`To Update ${item.title}, run the command below (or type update) in the LXC Console.`}
/>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { Category } from "@/lib/types";
import ScriptAccordion from "./ScriptAccordion";
const Sidebar = ({
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
}) => {
return (
<div className="flex min-w-72 flex-col sm:max-w-72">
<div className="flex items-end justify-between pb-4">
<h1 className="text-xl font-bold">Categories</h1>
<p className="text-xs italic text-muted-foreground">
{items.reduce(
(acc, category) => acc + category.expand.items.length,
0,
)}{" "}
Total scripts
</p>
</div>
<div className="rounded-lg">
<ScriptAccordion items={items} selectedScript={selectedScript} setSelectedScript={setSelectedScript} />
</div>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,98 @@
"use client";
export const dynamic = "force-static";
import ScriptItem from "@/app/scripts/_components/ScriptItem";
import { Category, Script } from "@/lib/types";
import { Loader2 } from "lucide-react";
import { Suspense, useEffect, useState } from "react";
import Sidebar from "./_components/Sidebar";
import { useQueryState } from "nuqs";
import {
LatestScripts,
MostViewedScripts,
} from "./_components/ScriptInfoBlocks";
function ScriptContent() {
const [selectedScript, setSelectedScript] = useQueryState("id");
const [links, setLinks] = useState<Category[]>([]);
const [item, setItem] = useState<Script>();
useEffect(() => {
if (selectedScript && links.length > 0) {
const script = links
.map((category) => category.expand.items)
.flat()
.find((script) => script.title === selectedScript);
setItem(script);
}
}, [selectedScript, links]);
const sortCategories = (categories: Category[]): Category[] => {
return categories.sort((a: Category, b: Category) => {
if (
a.catagoryName === "Proxmox VE Tools" &&
b.catagoryName !== "Proxmox VE Tools"
) {
return -1;
} else if (
a.catagoryName !== "Proxmox VE Tools" &&
b.catagoryName === "Proxmox VE Tools"
) {
return 1;
} else {
return a.catagoryName.localeCompare(b.catagoryName);
}
});
};
useEffect(() => {
fetch("api/categories")
.then((response) => response.json())
.then((categories) => {
const sortedCategories = sortCategories(categories);
setLinks(sortedCategories);
})
.catch((error) => console.error(error));
}, []);
return (
<div className="mb-3">
<div className="mt-20 flex sm:px-4 xl:px-0">
<div className="hidden sm:flex">
<Sidebar
items={links}
selectedScript={selectedScript}
setSelectedScript={setSelectedScript}
/>
</div>
<div className="mx-7 w-full sm:mx-0 sm:ml-7">
{selectedScript && item ? (
<ScriptItem item={item} setSelectedScript={setSelectedScript} />
) : (
<div className="flex w-full flex-col gap-5">
<LatestScripts items={links} />
<MostViewedScripts items={links} />
</div>
)}
</div>
</div>
</div>
);
}
export default function Page() {
return (
<Suspense
fallback={
<div className="flex h-screen w-full flex-col items-center justify-center gap-5 bg-background px-4 md:px-6">
<div className="space-y-2 text-center">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
</div>
}
>
<ScriptContent />
</Suspense>
);
}

View File

@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://community-scripts.github.io/Proxmox/",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.8,
},
{
url: "https://community-scripts.github.io/Proxmox/scripts",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
];
}

View File

@ -0,0 +1,130 @@
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useEffect } from "react";
import { Button } from "./ui/button";
import { DialogTitle } from "./ui/dialog";
const sortCategories = (categories: Category[]): Category[] => {
return categories.sort((a: Category, b: Category) => {
if (
a.catagoryName === "Proxmox VE Tools" &&
b.catagoryName !== "Proxmox VE Tools"
) {
return -1;
} else if (
a.catagoryName !== "Proxmox VE Tools" &&
b.catagoryName === "Proxmox VE Tools"
) {
return 1;
} else {
return a.catagoryName.localeCompare(b.catagoryName);
}
});
};
export default function CommandMenu() {
const [open, setOpen] = React.useState(false);
const [links, setLinks] = React.useState<Category[]>([]);
const router = useRouter();
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const fetchCategories = async () => {
setIsLoading(true);
fetch("api/categories")
.then((response) => response.json())
.then((categories) => {
const sortedCategories = sortCategories(categories);
setLinks(sortedCategories);
setIsLoading(false);
})
.catch((error) => {
setIsLoading(false);
console.error(error)
});
};
return (
<>
<Button
variant="outline"
className={cn(
"relative h-9 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64",
)}
onClick={() => {
fetchCategories();
setOpen(true)
}}
>
<span className="inline-flex">Search scripts...</span>
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.45rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<DialogTitle className="sr-only">Search scripts</DialogTitle>
<CommandInput placeholder="search for a script..." />
<CommandList>
<CommandEmpty>{isLoading ? "Loading..." : "No scripts found."}</CommandEmpty>
{links.map((category) => (
<CommandGroup
key={"category:" + category.catagoryName}
heading={category.catagoryName}
>
{category.expand.items.map((script) => (
<CommandItem
key={"script:" + script.id}
value={script.title}
onSelect={() => {
setOpen(false);
router.push(`/scripts?id=${script.title}`);
}}
>
<div className="flex gap-2" onClick={() => setOpen(false)}>
<Image
src={script.logo}
unoptimized
height={16}
onError={(e) =>
((e.currentTarget as HTMLImageElement).src =
"/logo.png")
}
width={16}
alt=""
className="h-5 w-5"
/>
<span>{script.title}</span>
<span className="text-sm text-muted-foreground">
{script.item_type}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</>
);
}

View File

@ -0,0 +1,23 @@
import Link from "next/link";
export default function Footer() {
return (
<div className="supports-backdrop-blur:bg-background/90 mt-auto flex border-t border-border bg-background/40 py-6 backdrop-blur-lg">
<div className="flex w-full justify-between">
<div className="mx-6 w-full max-w-7xl text-sm text-muted-foreground">
Website build by the community. The source code is avaliable on{" "}
<Link
href="https://github.com/community-scripts/Proxmox"
target="_blank"
rel="noreferrer"
className="font-semibold underline-offset-2 duration-300 hover:underline"
data-umami-event="View Website Source Code on Github"
>
GitHub
</Link>
.
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
"use client";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { navbarLinks } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import CommandMenu from "./CommandMenu";
import StarOnGithubButton from "./ui/star-on-github-button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
export const dynamic = "force-dynamic";
function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<>
<div
className={`fixed left-0 top-0 z-50 flex w-screen justify-center px-4 xl:px-0 ${
isScrolled ? "glass border-b bg-background/50" : ""
}`}
>
<div className="flex h-20 w-full max-w-7xl flex-row-reverse items-center justify-between sm:flex-row">
<Link
href={"/"}
className="flex cursor-pointer flex-row-reverse items-center gap-2 font-semibold sm:flex-row"
>
<Image
height={18}
unoptimized
width={18}
alt="logo"
src="logo.png"
/>
<span className="hidden lg:block">Proxmox VE Helper-Scripts</span>
</Link>
{/* <MobileNav /> */}
<div className="flex gap-2">
<CommandMenu />
<StarOnGithubButton />
{navbarLinks.map(({ href, event, icon, text }) => (
<TooltipProvider key={event}>
<Tooltip delayDuration={100}>
<TooltipTrigger>
<Button variant="ghost" size={"icon"} asChild>
<Link
target="_blank"
href={href}
data-umami-event={event}
>
{icon}
<span className="sr-only">{text}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
type="button"
size="icon"
className={cn("px-2")}
aria-label="Toggle theme"
onClick={() =>
setTheme(theme === "dark" ? "light" : "dark")
}
>
<SunIcon className="size-[1.2rem] text-neutral-800 dark:hidden dark:text-neutral-200" />
<MoonIcon className="hidden size-[1.2rem] text-neutral-800 dark:block dark:text-neutral-200" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Theme Toggle
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</>
);
}
export default Navbar;

View File

@ -0,0 +1,28 @@
import { ClipboardIcon } from "lucide-react";
import handleCopy from "./handleCopy";
export default function TextCopyBlock(description: string) {
const pattern = /`([^`]*)`/g;
const parts = description.split(pattern);
const formattedDescription = parts.map((part: string, index: number) => {
if (index % 2 === 1) {
return (
<span
key={index}
className="bg-secondary py-1 px-2 rounded-lg inline-flex items-center gap-2"
>
{part}
<ClipboardIcon
className="size-3 cursor-pointer"
onClick={() => handleCopy("command", part)}
/>
</span>
);
} else {
return part;
}
});
return formattedDescription;
}

View File

@ -0,0 +1,10 @@
import { ClipboardCheck } from "lucide-react";
import { toast } from "sonner";
export default function handleCopy(type: string, value: string) {
navigator.clipboard.writeText(value);
toast.success(`copied ${type} to clipboard`, {
icon: <ClipboardCheck className="h-4 w-4" />,
});
}

View File

@ -0,0 +1,8 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,57 @@
"use client";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-1 pr-2 font-medium transition-all [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden py-1 text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@ -0,0 +1,26 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
export default function AnimatedGradientText({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"group relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-2xl bg-white/40 px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#8fdfff1f] backdrop-blur-sm transition-shadow duration-500 ease-out [--bg-size:300%] hover:shadow-[inset_0_-5px_10px_#8fdfff3f] dark:bg-black/40",
className,
)}
>
<div
className={`absolute inset-0 block h-full w-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] p-[1px] [border-radius:inherit] ![mask-composite:subtract] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`}
/>
{children}
</div>
);
}

View File

@ -0,0 +1,39 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-1.5 py-0.1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent text-primary-foreground border-primary-foreground",
secondary:
"border-transparent text-secondary-foreground border-secondary-foreground",
destructive:
"border-transparent text-destructive-foreground border-destructive-foreground",
outline: "text-foreground",
success: "text-green-500 border-green-500",
warning: "text-yellow-500 border-yellow-500",
failure: "text-red-500 border-red-500",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
expandIcon:
"group relative text-primary-foreground bg-primary hover:bg-primary/90",
ringHover:
"bg-primary text-primary-foreground transition-all duration-300 hover:bg-primary/90 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
shine:
"text-primary-foreground animate-shine bg-gradient-to-r from-primary via-primary/75 to-primary bg-[length:400%_100%] ",
gooeyRight:
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:-z-10 before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-gradient-to-r from-zinc-400 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%] ",
gooeyLeft:
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:-z-10 after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-gradient-to-l from-zinc-400 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%] ",
linkHover1:
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
linkHover2:
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9 ",
null: "py-1 px-3 rouded-xs",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
interface IconProps {
Icon: React.ElementType;
iconPlacement: "left" | "right";
}
interface IconRefProps {
Icon?: never;
iconPlacement?: undefined;
}
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export type ButtonIconProps = IconProps | IconRefProps;
const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps & ButtonIconProps
>(
(
{
className,
variant,
size,
asChild = false,
Icon,
iconPlacement,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{Icon && iconPlacement === "left" && (
<div className="group-hover:translate-x-100 w-0 translate-x-[0%] pr-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:pr-2 group-hover:opacity-100">
<Icon />
</div>
)}
<Slottable>{props.children}</Slottable>
{Icon && iconPlacement === "right" && (
<div className="w-0 translate-x-[100%] pl-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pl-2 group-hover:opacity-100">
<Icon />
</div>
)}
</Comp>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,89 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn(
"min-h-[40px] text-sm text-muted-foreground sm:min-h-[60px]",
className,
)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("mt-auto items-center p-4 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@ -0,0 +1,66 @@
"use client";
import { cn } from "@/lib/utils";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Card } from "./card";
export default function CodeCopyButton({
children,
}: {
children: React.ReactNode;
}) {
const [hasCopied, setHasCopied] = useState(false);
const isMobile = window.innerWidth <= 640;
useEffect(() => {
if (hasCopied) {
setTimeout(() => {
setHasCopied(false);
}, 2000);
}
}, [hasCopied]);
const handleCopy = (type: string, value: any) => {
navigator.clipboard.writeText(value);
setHasCopied(true);
let warning = localStorage.getItem("warning");
if (warning === null) {
localStorage.setItem("warning", "1");
setTimeout(() => {
toast.error(
"Be careful when copying scripts from the internet. Always remember check the source!",
{ duration: 8000 },
);
}, 500);
}
// toast.success(`copied ${type} to clipboard`, {
// icon: <ClipboardCheck className="h-4 w-4" />,
// });
};
return (
<div className="mt-4 flex">
<Card className="flex items-center overflow-x-auto bg-primary-foreground pl-4">
<div className="overflow-x-auto whitespace-pre-wrap text-nowrap break-all pr-4 text-sm">
{!isMobile && children ? children : "Copy install command"}
</div>
<div
className={cn(" right-0 cursor-pointer bg-muted px-3 py-4")}
onClick={() => handleCopy("install command", children)}
>
{hasCopied ? (
<CheckIcon className="h-4 w-4" />
) : (
<ClipboardIcon className="h-4 w-4" />
)}
<span className="sr-only">Copy</span>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { Clipboard, Copy } from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "./button";
import { Separator } from "./separator";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:border-primary hover:text-accent-foreground",
secondary:
"bg-secondary border-secondary text-secondary-foreground hover:bg-secondary/80 hover:border-primary",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
null: "py-1 px-3 rouded-xs",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const handleCopy = (type: string, value: string) => {
navigator.clipboard.writeText(value);
let amountOfScriptsCopied = localStorage.getItem("amountOfScriptsCopied");
if (amountOfScriptsCopied === null) {
localStorage.setItem("amountOfScriptsCopied", "1");
} else {
amountOfScriptsCopied = (parseInt(amountOfScriptsCopied) + 1).toString();
localStorage.setItem("amountOfScriptsCopied", amountOfScriptsCopied);
if (
parseInt(amountOfScriptsCopied) === 3 ||
parseInt(amountOfScriptsCopied) === 10 ||
parseInt(amountOfScriptsCopied) === 25 ||
parseInt(amountOfScriptsCopied) === 50 ||
parseInt(amountOfScriptsCopied) === 100
) {
setTimeout(() => {
toast.info(
<div className="flex flex-col gap-3">
<p className="lg">
If you find these scripts useful, please consider starring the
repository on GitHub. It helps a lot!
</p>
<div>
<Button className="text-white">
<Link
href="https://github.com/community-scripts/ProxmoxVE"
data-umami-event="Star on Github"
target="_blank"
>
Star on GitHub 💫
</Link>
</Button>
</div>
</div>,
{ duration: 8000 },
);
}, 500);
}
}
toast.success(
<div className="flex items-center gap-2">
<Clipboard className="h-4 w-4" />
<span>Copied {type} to clipboard</span>
</div>,
);
};
export interface CodeBlockProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
code: string;
}
const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
({ className, variant, size, asChild = false, code }, ref) => {
const copyToClipboard = () => {
navigator.clipboard.writeText(code);
};
return (
<div
style={{
position: "relative",
marginBottom: "1rem",
display: "flex",
gap: "8px",
}}
ref={ref}
>
<pre
className={cn(
buttonVariants({ variant, size, className }),
" flex flex-row p-4",
)}
>
<p className="flex items-center gap-2">
{code} <Separator orientation="vertical" />{" "}
<Copy
className="cursor-pointer"
size={16}
onClick={() => handleCopy("install command", code)}
/>
</p>
</pre>
</div>
);
},
);
CodeBlock.displayName = "CodeBlock";
export { CodeBlock, buttonVariants };

View File

@ -0,0 +1,155 @@
"use client";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[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>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
};

View File

@ -0,0 +1,122 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-51%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,200 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"glass z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover/50 p-1 text-popover-foreground shadow-md 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",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,128 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@ -0,0 +1,61 @@
"use client";
import { useInView, useMotionValue, useSpring } from "framer-motion";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export default function NumberTicker({
value,
direction = "up",
delay = 0,
className,
decimalPlaces = 0,
}: {
value: number;
direction?: "up" | "down";
className?: string;
delay?: number; // delay in s
decimalPlaces?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? value : 0);
const springValue = useSpring(motionValue, {
damping: 60,
stiffness: 100,
});
const isInView = useInView(ref as React.RefObject<Element>, {
once: true,
margin: "0px",
});
useEffect(() => {
isInView &&
setTimeout(() => {
motionValue.set(direction === "down" ? 0 : value);
}, delay * 1000);
}, [motionValue, isInView, delay, value, direction]);
useEffect(
() =>
springValue.on("change", (latest) => {
if (ref.current) {
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces],
);
return (
<span
className={cn(
"inline-block tabular-nums text-black dark:text-white tracking-wider",
className,
)}
ref={ref}
/>
);
}

View File

@ -0,0 +1,283 @@
"use client";
import { cn } from "@/lib/utils";
import React, { useEffect, useRef, useState } from "react";
interface MousePosition {
x: number;
y: number;
}
function MousePosition(): MousePosition {
const [mousePosition, setMousePosition] = useState<MousePosition>({
x: 0,
y: 0,
});
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
return mousePosition;
}
interface ParticlesProps {
className?: string;
quantity?: number;
staticity?: number;
ease?: number;
size?: number;
refresh?: boolean;
color?: string;
vx?: number;
vy?: number;
}
function hexToRgb(hex: string): number[] {
hex = hex.replace("#", "");
if (hex.length === 3) {
hex = hex
.split("")
.map((char) => char + char)
.join("");
}
const hexInt = parseInt(hex, 16);
const red = (hexInt >> 16) & 255;
const green = (hexInt >> 8) & 255;
const blue = hexInt & 255;
return [red, green, blue];
}
const Particles: React.FC<ParticlesProps> = ({
className = "",
quantity = 100,
staticity = 50,
ease = 50,
size = 0.4,
refresh = false,
color = "#ffffff",
vx = 0,
vy = 0,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const context = useRef<CanvasRenderingContext2D | null>(null);
const circles = useRef<Circle[]>([]);
const mousePosition = MousePosition();
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
useEffect(() => {
if (canvasRef.current) {
context.current = canvasRef.current.getContext("2d");
}
initCanvas();
animate();
window.addEventListener("resize", initCanvas);
return () => {
window.removeEventListener("resize", initCanvas);
};
}, [color]);
useEffect(() => {
onMouseMove();
}, [mousePosition.x, mousePosition.y]);
useEffect(() => {
initCanvas();
}, [refresh]);
const initCanvas = () => {
resizeCanvas();
drawParticles();
};
const onMouseMove = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const { w, h } = canvasSize.current;
const x = mousePosition.x - rect.left - w / 2;
const y = mousePosition.y - rect.top - h / 2;
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
if (inside) {
mouse.current.x = x;
mouse.current.y = y;
}
}
};
type Circle = {
x: number;
y: number;
translateX: number;
translateY: number;
size: number;
alpha: number;
targetAlpha: number;
dx: number;
dy: number;
magnetism: number;
};
const resizeCanvas = () => {
if (canvasContainerRef.current && canvasRef.current && context.current) {
circles.current.length = 0;
canvasSize.current.w = canvasContainerRef.current.offsetWidth;
canvasSize.current.h = canvasContainerRef.current.offsetHeight;
canvasRef.current.width = canvasSize.current.w * dpr;
canvasRef.current.height = canvasSize.current.h * dpr;
canvasRef.current.style.width = `${canvasSize.current.w}px`;
canvasRef.current.style.height = `${canvasSize.current.h}px`;
context.current.scale(dpr, dpr);
}
};
const circleParams = (): Circle => {
const x = Math.floor(Math.random() * canvasSize.current.w);
const y = Math.floor(Math.random() * canvasSize.current.h);
const translateX = 0;
const translateY = 0;
const pSize = Math.floor(Math.random() * 2) + size;
const alpha = 0;
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
const dx = (Math.random() - 0.5) * 0.1;
const dy = (Math.random() - 0.5) * 0.1;
const magnetism = 0.1 + Math.random() * 4;
return {
x,
y,
translateX,
translateY,
size: pSize,
alpha,
targetAlpha,
dx,
dy,
magnetism,
};
};
const rgb = hexToRgb(color);
const drawCircle = (circle: Circle, update = false) => {
if (context.current) {
const { x, y, translateX, translateY, size, alpha } = circle;
context.current.translate(translateX, translateY);
context.current.beginPath();
context.current.arc(x, y, size, 0, 2 * Math.PI);
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
context.current.fill();
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!update) {
circles.current.push(circle);
}
}
};
const clearContext = () => {
if (context.current) {
context.current.clearRect(
0,
0,
canvasSize.current.w,
canvasSize.current.h,
);
}
};
const drawParticles = () => {
clearContext();
const particleCount = quantity;
for (let i = 0; i < particleCount; i++) {
const circle = circleParams();
drawCircle(circle);
}
};
const remapValue = (
value: number,
start1: number,
end1: number,
start2: number,
end2: number,
): number => {
const remapped =
((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
return remapped > 0 ? remapped : 0;
};
const animate = () => {
clearContext();
circles.current.forEach((circle: Circle, i: number) => {
// Handle the alpha value
const edge = [
circle.x + circle.translateX - circle.size, // distance from left edge
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
circle.y + circle.translateY - circle.size, // distance from top edge
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
];
const closestEdge = edge.reduce((a, b) => Math.min(a, b));
const remapClosestEdge = parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
);
if (remapClosestEdge > 1) {
circle.alpha += 0.02;
if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha;
}
} else {
circle.alpha = circle.targetAlpha * remapClosestEdge;
}
circle.x += circle.dx + vx;
circle.y += circle.dy + vy;
circle.translateX +=
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
ease;
circle.translateY +=
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
ease;
drawCircle(circle, true);
// circle gets out of the canvas
if (
circle.x < -circle.size ||
circle.x > canvasSize.current.w + circle.size ||
circle.y < -circle.size ||
circle.y > canvasSize.current.h + circle.size
) {
// remove the circle from the array
circles.current.splice(i, 1);
// create a new circle
const newCircle = circleParams();
drawCircle(newCircle);
// update the circle position
}
});
window.requestAnimationFrame(animate);
};
return (
<div
className={cn("pointer-events-none", className)}
ref={canvasContainerRef}
aria-hidden="true"
>
<canvas ref={canvasRef} className="size-full" />
</div>
);
};
export default Particles;

View File

@ -0,0 +1,31 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,140 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@ -0,0 +1,31 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useEffect, useState } from "react";
import { FaGithub, FaStar } from "react-icons/fa";
import NumberTicker from "./number-ticker";
import { buttonVariants } from "./button";
export default function StarOnGithubButton() {
const [stars, setStars] = useState(0);
useEffect(() => {
const fetchStars = async () => {
try {
const res = await fetch("https://api.github.com/repos/community-scripts/ProxmoxVE", {
next: { revalidate: 60 * 60 * 24 },
});
if (res.ok) {
const data = await res.json();
setStars(data.stargazers_count || stars);
}
} catch (error) {
console.error("Error fetching stars:", error);
}
};
fetchStars();
}, [stars]);
return (
<Link
className={cn(
buttonVariants(),
"hidden h-9 min-w-[240px] gap-2 overflow-hidden whitespace-pre sm:flex lg:flex",
"group relative justify-center gap-2 rounded-md transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-2",
)}
target="_blank"
href="https://github.com/community-scripts/ProxmoxVE"
>
<span className="absolute right-0 -mt-12 h-32 translate-x-12 rotate-12 bg-white opacity-10 transition-all duration-1000 ease-out group-hover:-translate-x-40" />
<div className="flex items-center">
<FaGithub className="size-4" />
<span className="ml-1">Star on GitHub</span>{" "}
</div>
<div className="ml-2 flex items-center gap-1 text-sm md:flex">
<FaStar className="size-4 text-gray-500 transition-all duration-300 group-hover:text-yellow-300" />
<NumberTicker
value={stars}
className="font-display font-medium text-white dark:text-black"
/>
</div>
</Link>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@ -0,0 +1,30 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md 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",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -0,0 +1,23 @@
import { MessagesSquare, Scroll } from "lucide-react";
import { FaGithub } from "react-icons/fa";
export const navbarLinks = [
{
href: "https://github.com/community-scripts/ProxmoxVE",
event: "Github",
icon: <FaGithub className="h-4 w-4" />,
text: "Github",
},
{
href: "https://github.com/community-scripts/ProxmoxVE/blob/main/CHANGELOG.md",
event: "Change Log",
icon: <Scroll className="h-4 w-4" />,
text: "Change Log",
},
{
href: "https://github.com/community-scripts/ProxmoxVE/discussions",
event: "Discussions",
icon: <MessagesSquare className="h-4 w-4" />,
text: "Discussions",
},
];

View File

@ -0,0 +1,10 @@
import PocketBase from "pocketbase";
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL);
export const pbBackup = new PocketBase(
process.env.NEXT_PUBLIC_POCKETBASE_URL_BACKUP,
);
export const getImageURL = (recordId: string, fileName: string) => {
return `${process.env.NEXT_PUBLIC_POCKETBASE_URL}/${recordId}/${fileName}`;
};

7
frontend/src/lib/time.ts Normal file
View File

@ -0,0 +1,7 @@
export function extractDate(dateString: string): string {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

55
frontend/src/lib/types.ts Normal file
View File

@ -0,0 +1,55 @@
// these are all the interfaces that are used in the site. these all come from the pocketbase database
export interface Script {
title: string;
description: string;
documentation: string;
website: string;
logo: string;
created: string;
updated: string;
id: string;
item_type: string;
interface: string;
installCommand: string;
port: number;
post_install: string;
default_cpu: string;
default_hdd: string;
default_ram: string;
isUpdateable: boolean;
isMostViewed: boolean;
privileged: boolean;
alpineScript: alpine_script;
expand: {
alpine_script: alpine_script;
alerts: alerts[];
default_login: default_login;
};
}
export interface Category {
catagoryName: string;
categoryId: string;
id: string;
created: string;
expand: {
items: Script[];
};
}
interface alpine_script {
installCommand: string;
default_cpu: string;
default_hdd: string;
default_ram: string;
}
interface alerts {
content: string;
}
interface default_login {
username: string;
password: string;
}

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

View File

@ -0,0 +1,82 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
* {
-ms-overflow-style: none;
}
::-webkit-scrollbar {
display: none;
}
.glass {
backdrop-filter: blur(15px) saturate(100%);
-webkit-backdrop-filter: blur(15px) saturate(100%);
}

180
frontend/tailwind.config.ts Normal file
View File

@ -0,0 +1,180 @@
import type { Config } from "tailwindcss";
const svgToDataUri = require("mini-svg-data-uri");
const {
default: flattenColorPalette,
} = require("tailwindcss/lib/util/flattenColorPalette");
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
shine: {
from: { backgroundPosition: "200% 0" },
to: { backgroundPosition: "-200% 0" },
},
gradient: {
to: {
backgroundPosition: "var(--bg-size) 0",
},
},
"shine-pulse": {
"0%": {
"background-position": "0% 0%",
},
"50%": {
"background-position": "100% 100%",
},
to: {
"background-position": "0% 0%",
},
},
moveHorizontal: {
"0%": {
transform: "translateX(-50%) translateY(-10%)",
},
"50%": {
transform: "translateX(50%) translateY(10%)",
},
"100%": {
transform: "translateX(-50%) translateY(-10%)",
},
},
moveInCircle: {
"0%": {
transform: "rotate(0deg)",
},
"50%": {
transform: "rotate(180deg)",
},
"100%": {
transform: "rotate(360deg)",
},
},
moveVertical: {
"0%": {
transform: "translateY(-50%)",
},
"50%": {
transform: "translateY(50%)",
},
"100%": {
transform: "translateY(-50%)",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
shine: "shine 8s ease-in-out infinite",
gradient: "gradient 8s linear infinite",
},
},
},
plugins: [
require(`tailwindcss-animated`),
require("tailwindcss-animate"),
addVariablesForColors,
function ({ matchUtilities, theme }: any) {
matchUtilities(
{
"bg-grid": (value: any) => ({
backgroundImage: `url("${svgToDataUri(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="${value}"><path d="M0 .5H31.5V32"/></svg>`,
)}")`,
}),
"bg-grid-small": (value: any) => ({
backgroundImage: `url("${svgToDataUri(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="8" height="8" fill="none" stroke="${value}"><path d="M0 .5H31.5V32"/></svg>`,
)}")`,
}),
"bg-dot": (value: any) => ({
backgroundImage: `url("${svgToDataUri(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="16" height="16" fill="none"><circle fill="${value}" id="pattern-circle" cx="10" cy="10" r="1.6257413380501518"></circle></svg>`,
)}")`,
}),
},
{
values: flattenColorPalette(theme("backgroundColor")),
type: "color",
},
);
},
],
} satisfies Config;
function addVariablesForColors({ addBase, theme }: any) {
let allColors = flattenColorPalette(theme("colors"));
let newVars = Object.fromEntries(
Object.entries(allColors).map(([key, val]) => [`--${key}`, val]),
);
addBase({
":root": newVars,
});
}
export default config;

33
frontend/tsconfig.json Normal file
View File

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