diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..901a577 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Stage 1: Install dependencies +FROM node:22-alpine AS deps +WORKDIR /app + +# Install dependencies based on your package manager +# If you use npm: +COPY package.json package-lock.json ./ +RUN npm ci + +# If you use yarn: +# COPY package.json yarn.lock ./ +# RUN yarn install --frozen-lockfile + +# Stage 2: Build the Next.js application +FROM node:22-alpine AS builder +WORKDIR /app + +# Copy dependencies from the 'deps' stage +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the Next.js application +# This will create the .next directory with optimized production build +RUN npm run build + +# Stage 3: Run the Next.js application in production +FROM node:22-alpine AS runner +WORKDIR /app + +# Set environment variables for Next.js production server +ENV NODE_ENV production + +# Next.js requires specific environment variables for standalone output +# If you are using `output: 'standalone'` in next.config.js, uncomment the following: +# ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp +# COPY --from=builder /app/.next/standalone ./ +# COPY --from=builder /app/.next/static ./.next/static +# COPY --from=builder /app/public ./public + +# If not using standalone output, copy the necessary files +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json + +# Expose the port Next.js runs on (default is 3000) +EXPOSE 3000 + +# Command to run the Next.js application +CMD ["npm", "start"] diff --git a/components.json b/components.json new file mode 100644 index 0000000..ffe928f --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index c85fb67..e01871b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,7 +10,13 @@ const compat = new FlatCompat({ }); const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + ...compat.config({ + extends: ['next'], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "react-hooks/exhaustive-deps": "off" + }, + }), ]; export default eslintConfig; diff --git a/makefile b/makefile new file mode 100644 index 0000000..26341cb --- /dev/null +++ b/makefile @@ -0,0 +1,64 @@ +.PHONY: install build start dev clean docker-build docker-run docker-stop + +# Define variables +APP_NAME := drb-frontend +DOCKER_IMAGE_NAME := $(APP_NAME):latest +DOCKER_CONTAINER_NAME := $(APP_NAME)-container + +# Default target when `make` is run without arguments +all: dev + +# Install Node.js dependencies +install: + @echo "Installing Node.js dependencies..." + npm install + +# Build the Next.js application for production +build: install + @echo "Building Next.js application for production..." + npm run build + +# Start the Next.js application in production mode +start: build + @echo "Starting Next.js application in production mode..." + npm start + +# Start the Next.js application in development mode +dev: install + @echo "Starting Next.js application in development mode..." + npm run dev + +# Clean up build artifacts and node_modules +clean: + @echo "Cleaning up build artifacts and node_modules..." + rm -rf .next out node_modules + @echo "Clean up complete." + +# Build the Docker image +docker-build: + @echo "Building Docker image: $(DOCKER_IMAGE_NAME)..." + docker build -t $(DOCKER_IMAGE_NAME) . + @echo "Docker image built successfully." + +# Run the Docker container +docker-run: docker-build + @echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..." + docker run -it --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME) + @echo "Docker container started. Access at http://localhost:3000" + +# Run the Docker container +docker-deploy: docker-build + @echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..." + docker run -d --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME) + @echo "Docker container started. Access at http://localhost:3000" + +# Stop and remove the Docker container +docker-stop: + @echo "Stopping and removing Docker container: $(DOCKER_CONTAINER_NAME)..." + docker stop $(DOCKER_CONTAINER_NAME) || true + docker rm $(DOCKER_CONTAINER_NAME) || true + @echo "Docker container stopped and removed." + +# Optional: Rebuild and rerun the Docker container +docker-rebuild-run: docker-stop docker-run + @echo "Rebuilding and rerunning Docker container." diff --git a/package-lock.json b/package-lock.json index c9e8b75..fbde172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,18 @@ "name": "drb-frontend", "version": "0.1.0", "dependencies": { + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.511.0", "next": "15.3.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwind-merge": "^3.3.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -21,6 +30,7 @@ "eslint": "^9", "eslint-config-next": "15.3.2", "tailwindcss": "^4", + "tw-animate-css": "^1.3.0", "typescript": "^5" } }, @@ -210,6 +220,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", + "integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz", + "integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==", + "dependencies": { + "@floating-ui/core": "^1.7.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -896,6 +940,585 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1223,7 +1846,7 @@ "version": "19.1.5", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz", "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.0.2" } @@ -1232,7 +1855,7 @@ "version": "19.1.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", - "dev": true, + "devOptional": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -1752,6 +2375,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2098,11 +2732,30 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2168,7 +2821,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -2293,6 +2946,11 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3111,6 +3769,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4123,6 +4789,14 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -4687,6 +5361,72 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-remove-scroll": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz", + "integrity": "sha512-sGsQtcjMqdQyijAHytfGEELB8FufGbfXIsvUTe+NLx1GDRJCXtCFLBLUI1eyZCKXXvbEU2C6gai0PZKoIE9Vbg==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5254,6 +5994,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz", + "integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", @@ -5369,6 +6118,15 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tw-animate-css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.0.tgz", + "integrity": "sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5533,6 +6291,47 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index faf2194..5f2b948 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,33 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "lint:fix": "next lint --fix" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.511.0", + "next": "15.3.2", "react": "^19.0.0", "react-dom": "^19.0.0", - "next": "15.3.2" + "tailwind-merge": "^3.3.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.2", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "tw-animate-css": "^1.3.0", + "typescript": "^5" } } diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..dc98be7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,122 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; } } - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/src/app/page.tsx b/src/app/page.tsx index e68abe6..b0cf0af 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,1069 @@ -import Image from "next/image"; +"use client"; +import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; + + +const API_BASE_URL = "http://localhost:5000"; + + +enum DemodTypes { + P25 = "P25", + DMR = "DMR", + NBFM = "NBFM" // Corrected from ANALOG to NBFM as per server.py +} + +interface TalkgroupTag { + talkgroup: string; + tagDec: number; +} + +interface DiscordId { + _id: string; + discord_id: string; + name: string; + token: string; + active: boolean; + guild_ids: string[]; +} + +interface System { + _id: string; + type: DemodTypes; + name: string; + frequencies: number[]; + location: string; + avail_on_nodes: string[]; + description?: string; // Optional + tags?: TalkgroupTag[]; // Optional + whitelist?: number[]; // Optional +} + +enum UserRoles { + ADMIN = "admin", + MOD = "mod", + USER = "user" +} + +interface UserDetails { + id: string; // Renamed from _id to id for client-side representation + username: string; + role: UserRoles; + // password_hash and api_key would not be sent to client +} + +// --- API Error Response Type --- +interface ErrorResponse { + message?: string; + detail?: string | any; // FastAPI often uses 'detail' which can be string or complex object +} + +// --- Auth Context --- +const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +// --- Auth Context Types --- +interface AuthContextType { + token: string | null; + user: UserDetails | null; + loading: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; + hasPermission: (requiredRole: UserRoles) => boolean; +} + +const AuthProvider: React.FC = ({ children }) => { + const [token, setToken] = useState(null); + const [user, setUser] = useState(null); // { id, username, role } + const [loading, setLoading] = useState(true); + + useEffect(() => { + const storedToken = localStorage.getItem('jwt_token'); + const storedUser = localStorage.getItem('user_data'); + if (storedToken && storedUser) { + setToken(storedToken); + try { + setUser(JSON.parse(storedUser)); + } catch (error) { + console.error("Failed to parse stored user data:", error); + localStorage.removeItem('user_data'); // Clear corrupted data + } + } + setLoading(false); + }, []); + + const login = async (username: string, password: string): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await response.json(); // Assume server always returns JSON + + if (response.ok) { + setToken(data.access_token); + // In a real app, you'd decode the token or fetch user details + // For now, placeholder user details are set. + // TODO: Fetch actual user details from a dedicated endpoint after login + const tempUser: UserDetails = { id: data.user_id || 'some-id', username, role: data.role || UserRoles.MOD }; // Placeholder, adjust with actual response + setUser(tempUser); + localStorage.setItem('jwt_token', data.access_token); + localStorage.setItem('user_data', JSON.stringify(tempUser)); + return true; + } else { + const errorData = data as ErrorResponse; + console.error('Login failed:', errorData.message || errorData.detail || response.statusText); + return false; + } + } catch (error) { + console.error('Network error during login:', error); + return false; + } + }; + + const logout = () => { + setToken(null); + setUser(null); + localStorage.removeItem('jwt_token'); + localStorage.removeItem('user_data'); + // Optionally redirect to login page or refresh to trigger AppContent's logic + }; + + const hasPermission = (requiredRole: UserRoles): boolean => { + if (!user || !user.role) return false; + const roleOrder: Record = { + [UserRoles.USER]: 0, + [UserRoles.MOD]: 1, + [UserRoles.ADMIN]: 2 + }; + return roleOrder[user.role] >= roleOrder[requiredRole]; + }; -export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ + {children} + + ); +}; - -
- +// Custom hook to consume AuthContext +const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === null) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; + +// --- Login Page Component --- +const LoginPage: React.FC = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const { login } = useAuth(); // This will now work or throw error if not in provider + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(''); + const success = await login(username, password); + if (!success) { + setError('Invalid username or password. Please try again.'); + } + // If login is successful, the AppContent component will handle navigation + }; + + return ( +
+ + + Login + + +
+
+ + ) => setUsername(e.target.value)} + required + className="mt-1" + /> +
+
+ + ) => setPassword(e.target.value)} + required + className="mt-1" + /> +
+ {error &&

{error}

} + +
+
+
); +}; + +interface BotsManagementProps { + token: string; } + +// --- Bots Management Component --- +const BotsManagement: React.FC = ({ token }) => { + const [bots, setBots] = useState([]); + const [discordIds, setDiscordIds] = useState([]); + const [systems, setSystems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [isAddIdDialogOpen, setIsAddIdDialogOpen] = useState(false); + const [newIdData, setNewIdData] = useState & { guild_ids: string }>({ + discord_id: '', + name: '', + token: '', + active: false, + guild_ids: '', + }); + const [editingId, setEditingId] = useState(null); + + const [isAssignDismissDialogOpen, setIsAssignDismissDialogOpen] = useState(false); + const [selectedBotClientId, setSelectedBotClientId] = useState(''); + const [selectedSystemId, setSelectedSystemId] = useState(''); + const [assignDismissAction, setAssignDismissAction] = useState<'assign' | 'dismiss'>('assign'); + + const fetchData = async (): Promise => { + setLoading(true); + setError(''); + let accumulatedError = ''; + + try { + const [botsRes, discordIdsRes, systemsRes] = await Promise.all([ + fetch(`${API_BASE_URL}/bots/`, { headers: { Authorization: `Bearer ${token}` } }), + fetch(`${API_BASE_URL}/discord_ids/`, { headers: { Authorization: `Bearer ${token}` } }), + fetch(`${API_BASE_URL}/systems/`, { headers: { Authorization: `Bearer ${token}` } }), + ]); + + // Bots + if (botsRes.ok) { + const botsData: string[] = await botsRes.json(); + setBots(botsData); + } else { + let errorMsg = `Failed to fetch bots (${botsRes.status})`; + try { + const errorData: ErrorResponse = await botsRes.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || botsRes.statusText}`; + } catch (e) { errorMsg += `: ${botsRes.statusText}`; } + accumulatedError += errorMsg + '\n'; + } + + // Discord IDs + if (discordIdsRes.ok) { + const discordIdsData: DiscordId[] = await discordIdsRes.json(); + setDiscordIds(discordIdsData); + } else { + let errorMsg = `Failed to fetch Discord IDs (${discordIdsRes.status})`; + try { + const errorData: ErrorResponse = await discordIdsRes.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || discordIdsRes.statusText}`; + } catch (e) { errorMsg += `: ${discordIdsRes.statusText}`; } + accumulatedError += errorMsg + '\n'; + } + + // Systems + if (systemsRes.ok) { + const systemsData: System[] = await systemsRes.json(); + setSystems(systemsData); + } else { + let errorMsg = `Failed to fetch systems (${systemsRes.status})`; + try { + const errorData: ErrorResponse = await systemsRes.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || systemsRes.statusText}`; + } catch (e) { errorMsg += `: ${systemsRes.statusText}`; } + accumulatedError += errorMsg + '\n'; + } + + if (accumulatedError) { + setError(accumulatedError.trim()); + } + + } catch (err: any) { + setError('Failed to fetch data. Check server connection or console.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (token) { + fetchData(); + } + }, [token]); + + + const handleAddId = async (): Promise => { + setError(''); + try { + const payload: Omit = { + ...newIdData, + guild_ids: newIdData.guild_ids.split(',').map(id => id.trim()).filter(id => id), + }; + const method = editingId ? 'PUT' : 'POST'; + const url = editingId ? `${API_BASE_URL}/discord_ids/${editingId._id}` : `${API_BASE_URL}/discord_ids/`; + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + // const data = await response.json(); // if needed + fetchData(); + setIsAddIdDialogOpen(false); + setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' }); + setEditingId(null); + } else { + let errorMsg = `Failed to ${editingId ? 'update' : 'add'} Discord ID (${response.status})`; + try { + const errorData: ErrorResponse = await response.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; + } catch (e) { errorMsg += `: ${response.statusText}`; } + setError(errorMsg); + } + } catch (err: any) { + setError(`Network error during ${editingId ? 'update' : 'add'} Discord ID.`); + console.error(err); + } + }; + + const handleDeleteId = async (id: string): Promise => { + if (!window.confirm('Are you sure you want to delete this Discord ID?')) return; + setError(''); + try { + const response = await fetch(`${API_BASE_URL}/discord_ids/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + // const data = await response.json(); // if needed + fetchData(); + } else { + let errorMsg = `Failed to delete Discord ID (${response.status})`; + try { + const errorData: ErrorResponse = await response.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; + } catch (e) { errorMsg += `: ${response.statusText}`; } + setError(errorMsg); + } + } catch (err: any) { + setError('Network error during delete Discord ID.'); + console.error(err); + } + }; + + const handleAssignDismiss = async (): Promise => { + setError(''); + if (!selectedBotClientId || !selectedSystemId) { + setError("Bot Client ID and System must be selected."); + return; + } + try { + const endpoint = assignDismissAction === 'assign' ? 'assign' : 'dismiss'; + const response = await fetch(`${API_BASE_URL}/systems/${selectedSystemId}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ client_id: selectedBotClientId }), + }); + if (response.ok) { + // const data = await response.json(); // if needed + fetchData(); // Refetch all data to reflect changes in system assignments + setIsAssignDismissDialogOpen(false); + setSelectedBotClientId(''); + setSelectedSystemId(''); + } else { + let errorMsg = `Failed to ${assignDismissAction} bot (${response.status})`; + try { + const errorData: ErrorResponse = await response.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; + } catch (e) { errorMsg += `: ${response.statusText}`; } + setError(errorMsg); + } + } catch (err: any) { + setError(`Network error during ${assignDismissAction} bot.`); + console.error(err); + } + }; + + if (loading) return

Loading bots and Discord IDs...

; + // Error display is now more consolidated from fetchData + if (error && !loading) return

{error}

; + + + return ( + + + Bots Management + + + {error &&

{error}

} +
+

Online Bots (Clients)

+ {/* TODO: This table needs data from a different endpoint (e.g., /nodes/get_online_bots) + to accurately show which Discord ID is assigned to an online client. + The current `bots` state is just a list of client_ids, not their full status. + For now, displaying client_id only. + */} + + + + Client ID + Assigned Discord ID (Name) + {/* This column requires mapping client_id to DiscordId which is not directly available from /bots endpoint */} + + + + {bots.length > 0 ? ( + bots.map((botClientId) => { + // Placeholder: To accurately display assigned Discord ID, need to fetch and map + // from an endpoint that provides { client_id: discord_id_details } + const assignedSystem = systems.find(sys => sys.avail_on_nodes.includes(botClientId)); + const assignedDiscordBot = discordIds.find(did => did.name === assignedSystem?.name); // This mapping is speculative + + return ( + + {botClientId} + + {assignedSystem ? `${assignedSystem.name} (System)` : "N/A or requires different mapping"} + {/* Displaying Discord ID's name if a system is found on this node. + This is an assumption. A better API would link client_id to discord_id directly. */} + + + ); + }) + ) : ( + + No bots currently online or reported by /bots. + + )} + +
+
+ +
+

Manage Discord IDs

+ + + + + + + Name + Discord ID + Token (Partial) + Active + Guilds + Actions + + + + {discordIds.length > 0 ? ( + discordIds.map((dId) => ( + + {dId.name} + {dId.discord_id} + {dId.token ? dId.token.substring(0, 8) + '...' : 'N/A'} + {dId.active ? 'Yes' : 'No'} + {dId.guild_ids.join(', ')} + + + + + + )) + ) : ( + + No Discord IDs found. + + )} + +
+
+
+ + {/* Add/Edit Discord ID Dialog */} + + + + {editingId ? 'Edit Discord ID' : 'Add New Discord ID'} + +
+
+ + setNewIdData({ ...newIdData, name: e.target.value })} + className="col-span-3" + /> +
+
+ + setNewIdData({ ...newIdData, discord_id: e.target.value })} + className="col-span-3" + /> +
+
+ + setNewIdData({ ...newIdData, token: e.target.value })} + className="col-span-3" + placeholder={editingId ? "Token hidden for security, re-enter to change" : ""} + /> +
+
+ + setNewIdData({ ...newIdData, guild_ids: e.target.value })} + className="col-span-3" + /> +
+
+ + setNewIdData({ ...newIdData, active: checked === true })} + className="col-span-3" + /> +
+
+ + + +
+
+ + {/* Assign/Dismiss Bot Dialog */} + + + + Assign/Dismiss Bot to System + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ ); +}; + +// --- Systems Management Component --- +interface SystemsManagementProps { + token: string; +} + +const SystemsManagement: React.FC = ({ token }) => { + const [systems, setSystems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [isAddSystemDialogOpen, setIsAddSystemDialogOpen] = useState(false); + const [newSystemData, setNewSystemData] = useState< + Omit & { + _id?: string; // Make _id optional for new system data + frequencies: string; + avail_on_nodes: string; + tags: string; + whitelist: string; + } + >({ + type: DemodTypes.P25, + name: '', + frequencies: '', + location: '', + avail_on_nodes: '', + description: '', + tags: '[]', // Default to empty JSON array string + whitelist: '', + }); + const [editingSystem, setEditingSystem] = useState(null); + + const fetchSystems = async (): Promise => { + setLoading(true); + setError(''); + try { + const response = await fetch(`${API_BASE_URL}/systems/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + const data: System[] = await response.json(); + setSystems(data); + } else { + let errorMsg = `Failed to fetch systems (${response.status})`; + try { + const errorData: ErrorResponse = await response.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; + } catch (e) { errorMsg += `: ${response.statusText}`; } + setError(errorMsg); + } + } catch (err: any) { + setError('Failed to fetch systems. Check server connection or console.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (token) { + fetchSystems(); + } + }, [token]); + + const handleAddSystem = async (): Promise => { + setError(''); + try { + let parsedTags: TalkgroupTag[] | undefined = undefined; + try { + parsedTags = newSystemData.tags ? JSON.parse(newSystemData.tags) : []; + if (!Array.isArray(parsedTags)) throw new Error("Tags must be a JSON array."); + } catch (e: any) { + setError(`Invalid JSON format for Tags: ${e.message}`); + return; + } + + const payload: Omit & { + _id?: string; + frequencies: number[]; + avail_on_nodes: string[]; + tags?: TalkgroupTag[]; + whitelist?: number[]; + } = { + name: newSystemData.name, + type: newSystemData.type, + location: newSystemData.location, + description: newSystemData.description || undefined, + frequencies: newSystemData.frequencies.split(',').map(f => parseInt(f.trim())).filter(f => !isNaN(f)), + avail_on_nodes: newSystemData.avail_on_nodes.split(',').map(node => node.trim()).filter(node => node), + tags: parsedTags, + whitelist: newSystemData.whitelist.split(',').map(w => parseInt(w.trim())).filter(w => !isNaN(w)), + }; + + if (editingSystem) { + payload._id = editingSystem._id; + } else if (newSystemData._id) { // Only include _id if provided for new system + payload._id = newSystemData._id; + } + + + const method = editingSystem ? 'PUT' : 'POST'; + const url = editingSystem ? `${API_BASE_URL}/systems/${editingSystem._id}` : + (payload._id ? `${API_BASE_URL}/systems/${payload._id}` : `${API_BASE_URL}/systems/`); + + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + // const data = await response.json(); // if needed + fetchSystems(); + setIsAddSystemDialogOpen(false); + setNewSystemData({ + _id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '', + avail_on_nodes: '', description: '', tags: '[]', whitelist: '', + }); + setEditingSystem(null); + } else { + let errorMsg = `Failed to ${editingSystem ? 'update' : 'add'} system (${response.status})`; + try { + const errorData: ErrorResponse = await response.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; + } catch (e) { errorMsg += `: ${response.statusText}`; } + setError(errorMsg); + } + } catch (err: any) { + setError(`Network error during ${editingSystem ? 'update' : 'add'} system. ${err.message}`); + console.error(err); + } + }; + + const handleDeleteSystem = async (id: string): Promise => { + if (!window.confirm('Are you sure you want to delete this system?')) return; + setError(''); + try { + const response = await fetch(`${API_BASE_URL}/systems/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + // const data = await response.json(); // if needed + fetchSystems(); + } else { + let errorMsg = `Failed to delete system (${response.status})`; + try { + const errorData: ErrorResponse = await response.json(); + errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; + } catch (e) { errorMsg += `: ${response.statusText}`; } + setError(errorMsg); + } + } catch (err: any) { + setError('Network error during delete system.'); + console.error(err); + } + }; + + if (loading) return

Loading systems...

; + if (error && !loading) return

{error}

; + + return ( + + + Systems Management + + + {error &&

{error}

} +
+

All Systems

+ + + + + + Name (_id) + Type + Frequencies + Location + Available Nodes + Actions + + + + {systems.length > 0 ? ( + systems.map((system: System) => ( + + {system.name} ({system._id}) + {system.type} + {system.frequencies.join(', ')} + {system.location} + {system.avail_on_nodes.join(', ')} + + + + + + )) + ) : ( + + No systems found. + + )} + +
+
+
+ + {/* Add/Edit System Dialog */} + + + + {editingSystem ? 'Edit System' : 'Add New System'} + +
+
+ + ) => setNewSystemData({ ...newSystemData, _id: e.target.value })} + className="col-span-3" + disabled={!!editingSystem} + placeholder={editingSystem ? newSystemData._id : "Leave blank to auto-generate, or specify"} + /> +
+
+ + ) => setNewSystemData({ ...newSystemData, name: e.target.value })} + className="col-span-3" + /> +
+
+ + +
+
+ + ) => setNewSystemData({ ...newSystemData, frequencies: e.target.value })} + className="col-span-3" + /> +
+
+ + ) => setNewSystemData({ ...newSystemData, location: e.target.value })} + className="col-span-3" + /> +
+
+ + ) => setNewSystemData({ ...newSystemData, avail_on_nodes: e.target.value })} + className="col-span-3" + /> +
+
+ +