mirror of
https://github.com/community-scripts/ProxmoxVE
synced 2025-01-10 19:05:09 +00:00
Merge pull request #63 from BramSuurdje/main
Add frontend to Scripts repostitory
This commit is contained in:
commit
0f493c1d19
83
.github/workflows/nextjs.yml
vendored
Normal file
83
.github/workflows/nextjs.yml
vendored
Normal 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
3
frontend/.env.local
Normal 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
5
frontend/.eslintrc.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"]
|
||||||
|
}
|
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal 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
5
frontend/.prettierignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
build
|
||||||
|
.contentlayer
|
3
frontend/.prettierrc
Normal file
3
frontend/.prettierrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-organize-imports"]
|
||||||
|
}
|
21
frontend/LICENSE
Normal file
21
frontend/LICENSE
Normal 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
17
frontend/components.json
Normal 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
4
frontend/example.env
Normal 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
|
25
frontend/next.config.mjs
Normal file
25
frontend/next.config.mjs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
webpack: (config) => {
|
||||||
|
config.resolve.alias.canvas = false;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_BUILD_TIME: `${Date.now()}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
output: "export",
|
||||||
|
basePath: "/ProxmoxVE",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
7546
frontend/package-lock.json
generated
Normal file
7546
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
frontend/package.json
Normal file
73
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
BIN
frontend/public/defaultimg.png
Normal file
BIN
frontend/public/defaultimg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 76 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
23
frontend/public/metadata/docker.json
Normal file
23
frontend/public/metadata/docker.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
20
frontend/public/metadata/nginxproxymanager.json
Normal file
20
frontend/public/metadata/nginxproxymanager.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
23
frontend/src/app/api/categories/route.ts
Normal file
23
frontend/src/app/api/categories/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
88
frontend/src/app/layout.tsx
Normal file
88
frontend/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
27
frontend/src/app/manifest.ts
Normal file
27
frontend/src/app/manifest.ts
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
20
frontend/src/app/not-found.tsx
Normal file
20
frontend/src/app/not-found.tsx
Normal 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
123
frontend/src/app/page.tsx
Normal 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'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'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>
|
||||||
|
);
|
||||||
|
}
|
13
frontend/src/app/robots.ts
Normal file
13
frontend/src/app/robots.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
147
frontend/src/app/scripts/_components/ScriptAccordion.tsx
Normal file
147
frontend/src/app/scripts/_components/ScriptAccordion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
219
frontend/src/app/scripts/_components/ScriptInfoBlocks.tsx
Normal file
219
frontend/src/app/scripts/_components/ScriptInfoBlocks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
95
frontend/src/app/scripts/_components/ScriptItem.tsx
Normal file
95
frontend/src/app/scripts/_components/ScriptItem.tsx
Normal 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;
|
19
frontend/src/app/scripts/_components/ScriptItems/Alerts.tsx
Normal file
19
frontend/src/app/scripts/_components/ScriptItems/Alerts.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import TextCopyBlock from "@/components/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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
74
frontend/src/app/scripts/_components/ScriptItems/Buttons.tsx
Normal file
74
frontend/src/app/scripts/_components/ScriptItems/Buttons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import TextCopyBlock from "@/components/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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
34
frontend/src/app/scripts/_components/Sidebar.tsx
Normal file
34
frontend/src/app/scripts/_components/Sidebar.tsx
Normal 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;
|
100
frontend/src/app/scripts/page.tsx
Normal file
100
frontend/src/app/scripts/page.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"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?_=${process.env.NEXT_PUBLIC_BUILD_TIME || Date.now()}`,
|
||||||
|
)
|
||||||
|
.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>
|
||||||
|
);
|
||||||
|
}
|
20
frontend/src/app/sitemap.ts
Normal file
20
frontend/src/app/sitemap.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
132
frontend/src/components/CommandMenu.tsx
Normal file
132
frontend/src/components/CommandMenu.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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?_=${process.env.NEXT_PUBLIC_BUILD_TIME || Date.now()}`,
|
||||||
|
)
|
||||||
|
.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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
23
frontend/src/components/Footer.tsx
Normal file
23
frontend/src/components/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
113
frontend/src/components/Navbar.tsx
Normal file
113
frontend/src/components/Navbar.tsx
Normal 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;
|
28
frontend/src/components/TextCopyBlock.tsx
Normal file
28
frontend/src/components/TextCopyBlock.tsx
Normal 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;
|
||||||
|
}
|
10
frontend/src/components/handleCopy.tsx
Normal file
10
frontend/src/components/handleCopy.tsx
Normal 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" />,
|
||||||
|
});
|
||||||
|
}
|
8
frontend/src/components/theme-provider.tsx
Normal file
8
frontend/src/components/theme-provider.tsx
Normal 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>;
|
||||||
|
}
|
57
frontend/src/components/ui/accordion.tsx
Normal file
57
frontend/src/components/ui/accordion.tsx
Normal 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 };
|
26
frontend/src/components/ui/animated-gradient-text.tsx
Normal file
26
frontend/src/components/ui/animated-gradient-text.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
39
frontend/src/components/ui/badge.tsx
Normal file
39
frontend/src/components/ui/badge.tsx
Normal 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 };
|
108
frontend/src/components/ui/button.tsx
Normal file
108
frontend/src/components/ui/button.tsx
Normal 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 };
|
89
frontend/src/components/ui/card.tsx
Normal file
89
frontend/src/components/ui/card.tsx
Normal 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,
|
||||||
|
};
|
66
frontend/src/components/ui/code-copy-button.tsx
Normal file
66
frontend/src/components/ui/code-copy-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
137
frontend/src/components/ui/codeblock.tsx
Normal file
137
frontend/src/components/ui/codeblock.tsx
Normal 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 };
|
155
frontend/src/components/ui/command.tsx
Normal file
155
frontend/src/components/ui/command.tsx
Normal 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,
|
||||||
|
};
|
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
200
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
};
|
25
frontend/src/components/ui/input.tsx
Normal file
25
frontend/src/components/ui/input.tsx
Normal 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 };
|
128
frontend/src/components/ui/navigation-menu.tsx
Normal file
128
frontend/src/components/ui/navigation-menu.tsx
Normal 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,
|
||||||
|
};
|
61
frontend/src/components/ui/number-ticker.tsx
Normal file
61
frontend/src/components/ui/number-ticker.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
283
frontend/src/components/ui/particles.tsx
Normal file
283
frontend/src/components/ui/particles.tsx
Normal 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;
|
31
frontend/src/components/ui/separator.tsx
Normal file
31
frontend/src/components/ui/separator.tsx
Normal 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 };
|
140
frontend/src/components/ui/sheet.tsx
Normal file
140
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
};
|
31
frontend/src/components/ui/sonner.tsx
Normal file
31
frontend/src/components/ui/sonner.tsx
Normal 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 };
|
53
frontend/src/components/ui/star-on-github-button.tsx
Normal file
53
frontend/src/components/ui/star-on-github-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
55
frontend/src/components/ui/tabs.tsx
Normal file
55
frontend/src/components/ui/tabs.tsx
Normal 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 };
|
30
frontend/src/components/ui/tooltip.tsx
Normal file
30
frontend/src/components/ui/tooltip.tsx
Normal 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 };
|
23
frontend/src/config/siteConfig.tsx
Normal file
23
frontend/src/config/siteConfig.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
];
|
10
frontend/src/lib/pocketbase.ts
Normal file
10
frontend/src/lib/pocketbase.ts
Normal 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
7
frontend/src/lib/time.ts
Normal 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
55
frontend/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
82
frontend/src/styles/globals.css
Normal file
82
frontend/src/styles/globals.css
Normal 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
180
frontend/tailwind.config.ts
Normal 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
33
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user