mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +00:00
@@ -1,8 +1,17 @@
|
||||
README.md
|
||||
.env
|
||||
compose.yaml
|
||||
assets
|
||||
**
|
||||
|
||||
Dockerfile
|
||||
.git
|
||||
.gitignore
|
||||
!backend/.cargo/**
|
||||
!backend/src/**
|
||||
!backend/Cargo.lock
|
||||
!backend/Cargo.toml
|
||||
|
||||
!frontend/next-env.d.ts
|
||||
!frontend/next.config.ts
|
||||
!frontend/package-lock.json
|
||||
!frontend/package.json
|
||||
!frontend/postcss.config.mjs
|
||||
!frontend/public/**
|
||||
!frontend/src/**
|
||||
!frontend/tsconfig.json
|
||||
|
||||
!scripts/**
|
||||
|
@@ -81,6 +81,10 @@ WORKDIR /app
|
||||
RUN addgroup -g 1001 -S appgroup && \
|
||||
adduser -S appuser -u 1001 -G appgroup
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY ./scripts/entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Copy run wrapper script
|
||||
COPY ./scripts/run_wrapper.sh ./
|
||||
RUN chmod +x run_wrapper.sh
|
||||
@@ -108,4 +112,5 @@ ENV BACKEND_BIND_PORT="8080"
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD /usr/local/bin/healthcheck.sh
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
CMD ["./run_wrapper.sh"]
|
||||
|
40
README.md
40
README.md
@@ -11,7 +11,7 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
<!-- vim-markdown-toc GFM -->
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🎫 Voucher Management](#-voucher-management)
|
||||
- [🎫 Voucher Management & WiFi QR Code](#-voucher-management--wifi-qr-code)
|
||||
- [🎨 Modern Interface](#-modern-interface)
|
||||
- [🔧 Technical Features](#-technical-features)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
@@ -28,7 +28,7 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🎫 Voucher Management
|
||||
### 🎫 Voucher Management & WiFi QR Code
|
||||
|
||||
- **Quick Create** - Generate guest vouchers with preset durations (1 hour to 1 week)
|
||||
- **Custom Create** - Full control over voucher parameters:
|
||||
@@ -41,6 +41,7 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
- **Search Vouchers** - Search vouchers by name
|
||||
- **Bulk Operations** - Select and delete multiple vouchers
|
||||
- **Auto-cleanup** - Remove expired vouchers with a single click
|
||||
- **QR Code** - Easily connect guests to your network
|
||||
|
||||
### 🎨 Modern Interface
|
||||
|
||||
@@ -110,18 +111,26 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Type | Description | Example (default if optional) |
|
||||
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol. | `https://unifi.example.com:8443` |
|
||||
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` |
|
||||
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) |
|
||||
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) |
|
||||
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) |
|
||||
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) |
|
||||
| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) |
|
||||
| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) |
|
||||
| `BACKEND_LOG_LEVEL` | Optional | Log level of the Rust backend. | `info`(default) |
|
||||
| `TIMEZONE` | Optional | Server [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). | `UTC` (default) |
|
||||
Make sure to configure the required variables. The optional variables generally have default values that you should not have to change.
|
||||
|
||||
To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and `WIFI_PASSWORD` variables.
|
||||
|
||||
| Variable | Type | Description | Example | Type |
|
||||
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol. | `https://unifi.example.com:8443` | `string` |
|
||||
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` |
|
||||
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) | `string` |
|
||||
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` |
|
||||
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` |
|
||||
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | `URL` |
|
||||
| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) | `IPv4` |
|
||||
| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) | `u16` |
|
||||
| `BACKEND_LOG_LEVEL` | Optional | Log level of the Rust backend. | `info`(default) | `trace\|debug\|info\|warn\|error` |
|
||||
| `TIMEZONE` | Optional | [Timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) used to format dates and time. | `UTC` (default) | [`timezone`](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) |
|
||||
| `WIFI_SSID` | Optional | WiFi SSID used for the QR code. (required for QR code to be generated) | `My WiFi SSID` | `string` |
|
||||
| `WIFI_PASSWORD` | Optional | WiFi password used for the QR code (required for QR code to be generated) | `My WiFi Password` | `string` |
|
||||
| `WIFI_TYPE` | Optional | WiFi security type used. Defaults to `WPA` if a password is provided and `nopass` otherwise. | `WPA` | `WPA\|WEP\|nopass` |
|
||||
| `WIFI_HIDDEN` | Optional | Whether the WiFi SSID is hidden or broadcasted. | `false` (default) | `bool` |
|
||||
|
||||
### Getting UniFi API Credentials
|
||||
|
||||
@@ -144,6 +153,9 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
- Check all environment variables are set
|
||||
- Verify Docker container has network access to UniFi controller
|
||||
- Check logs: `docker compose logs unifi-voucher-manager`
|
||||
- **The WiFi QR code button is seems disabled**
|
||||
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code.
|
||||
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab).
|
||||
|
||||
### Getting Help
|
||||
|
||||
|
@@ -1,5 +0,0 @@
|
||||
target
|
||||
README.md
|
||||
|
||||
.dockerignore
|
||||
.gitignore
|
@@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
|
||||
.dockerignore
|
||||
.gitignore
|
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "unifi-voucher-manager",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-git",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "unifi-voucher-manager",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0-git",
|
||||
"dependencies": {
|
||||
"next": "15.4.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
@@ -1534,6 +1535,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
|
@@ -9,16 +9,17 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.4.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.4.5"
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
2
frontend/public/qr.svg
Normal file
2
frontend/public/qr.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 9h6V3H3zm1-5h4v4H4zm1 1h2v2H5zm10 4h6V3h-6zm1-5h4v4h-4zm1 1h2v2h-2zM3 21h6v-6H3zm1-5h4v4H4zm1 1h2v2H5zm15 2h1v2h-2v-3h1zm0-3h1v1h-1zm0-1v1h-1v-1zm-10 2h1v4h-1v-4zm-4-7v2H4v-1H3v-1h3zm4-3h1v1h-1zm3-3v2h-1V3h2v1zm-3 0h1v1h-1zm10 8h1v2h-2v-1h1zm-1-2v1h-2v2h-2v-1h1v-2h3zm-7 4h-1v-1h-1v-1h2v2zm6 2h1v1h-1zm2-5v1h-1v-1zm-9 3v1h-1v-1zm6 5h1v2h-2v-2zm-3 0h1v1h-1v1h-2v-1h1v-1zm0-1v-1h2v1zm0-5h1v3h-1v1h-1v1h-1v-2h-1v-1h3v-1h-1v-1zm-9 0v1H4v-1zm12 4h-1v-1h1zm1-2h-2v-1h2zM8 10h1v1H8v1h1v2H8v-1H7v1H6v-2h1v-2zm3 0V8h3v3h-2v-1h1V9h-1v1zm0-4h1v1h-1zm-1 4h1v1h-1zm3-3V6h1v1z"/><path fill="none" d="M0 0h24v24H0z"/></svg>
|
After Width: | Height: | Size: 828 B |
11
frontend/public/unifi.svg
Normal file
11
frontend/public/unifi.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" >
|
||||
<path fill="url(#a)" d="M0 10c0-3.5 0-5.25.681-6.587A6.25 6.25 0 0 1 3.413.68C4.75 0 6.5 0 10 0h12c3.5 0 5.25 0 6.587.681a6.25 6.25 0 0 1 2.732 2.732C32 4.75 32 6.5 32 10v12c0 3.5 0 5.25-.681 6.587a6.25 6.25 0 0 1-2.732 2.732C27.25 32 25.5 32 22 32H10c-3.5 0-5.25 0-6.587-.681A6.25 6.25 0 0 1 .68 28.587C0 27.25 0 25.5 0 22V10Z"/>
|
||||
<path fill="#fff" d="M23.5 8.88h-1v1h1v-1Zm-3.499 7.003v-2.004h2v2H24v.634c0 .733-.062 1.601-.206 2.283-.08.38-.201.76-.344 1.123a7.834 7.834 0 0 1-1.335 2.241l-.017.02-.028.033c-.077.09-.153.18-.237.267a7.888 7.888 0 0 1-.302.302 7.95 7.95 0 0 1-4.69 2.179 11 11 0 0 1-.841.044 11.84 11.84 0 0 1-.841-.044 7.954 7.954 0 0 1-4.69-2.179 7.888 7.888 0 0 1-.302-.302c-.088-.091-.167-.184-.248-.279l-.034-.04a7.834 7.834 0 0 1-1.335-2.242 7.132 7.132 0 0 1-.345-1.123C8.062 18.113 8 17.246 8 16.513V9.005h3.999v6.877s0 .528.006.7l.002.04c.008.224.017.442.04.66.066.618.202 1.203.484 1.699.081.143.164.282.263.414a4.022 4.022 0 0 0 2.658 1.572c.136.02.41.037.548.037.137 0 .412-.017.547-.037a4.022 4.022 0 0 0 2.66-1.572c.099-.132.18-.27.262-.414.282-.496.418-1.081.484-1.699.024-.218.032-.437.04-.66l.002-.04c.006-.172.006-.7.006-.7Z"/>
|
||||
<path fill="#fff" d="M20.5 10.38H22v1.5h2v2h-2v-2h-1.5v-1.5Z"/>
|
||||
<defs>
|
||||
<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(0 32 -32 0 16 0)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#006FFF"/>
|
||||
<stop offset="1" stop-color="#003C9E"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
@@ -79,11 +79,15 @@
|
||||
--shadow-elevation-dark: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 0. Semantic Color Utilities - Theme System */
|
||||
/* -------------------------------------------------- */
|
||||
/* ========================================================================== */
|
||||
/* 0. Semantic Color Utilities */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* Text Colors - Semantic */
|
||||
/* ---------------------------------- */
|
||||
/* Base */
|
||||
/* ---------------------------------- */
|
||||
|
||||
/* Text */
|
||||
@utility text-primary {
|
||||
@apply text-neutral-900 dark:text-neutral-50;
|
||||
}
|
||||
@@ -100,7 +104,58 @@
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
/* Status Text Colors */
|
||||
/* Background */
|
||||
@utility bg-page {
|
||||
@apply bg-neutral-50 dark:bg-neutral-900;
|
||||
}
|
||||
@utility bg-surface {
|
||||
@apply bg-white dark:bg-neutral-800;
|
||||
}
|
||||
@utility bg-overlay {
|
||||
@apply bg-black/50 dark:bg-black/70;
|
||||
}
|
||||
@utility bg-disabled {
|
||||
@apply bg-neutral-400 dark:bg-neutral-600;
|
||||
}
|
||||
|
||||
/* Borders */
|
||||
@utility border-default {
|
||||
@apply border-neutral-200 dark:border-neutral-700;
|
||||
}
|
||||
@utility border-subtle {
|
||||
@apply border-neutral-100 dark:border-neutral-800;
|
||||
}
|
||||
@utility border-intense {
|
||||
@apply border-neutral-400 dark:border-neutral-500;
|
||||
}
|
||||
@utility border-accent {
|
||||
@apply border-primary-500 dark:border-primary-500;
|
||||
}
|
||||
@utility border-emphasis {
|
||||
@apply border-primary-600 dark:border-primary-300;
|
||||
}
|
||||
|
||||
/* Focus */
|
||||
@utility focus-accent {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500;
|
||||
}
|
||||
@utility focus-danger {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
/* Selected */
|
||||
@utility selected-accent {
|
||||
@apply border-accent bg-primary-600;
|
||||
}
|
||||
@utility unselected-neutral {
|
||||
@apply border-default bg-surface;
|
||||
}
|
||||
|
||||
/* ---------------------------------- */
|
||||
/* Status */
|
||||
/* ---------------------------------- */
|
||||
|
||||
/* Text */
|
||||
@utility text-status-success {
|
||||
@apply text-success-700 dark:text-success-400;
|
||||
}
|
||||
@@ -114,21 +169,7 @@
|
||||
@apply text-primary-700 dark:text-primary-400;
|
||||
}
|
||||
|
||||
/* Background Colors - Semantic */
|
||||
@utility bg-page {
|
||||
@apply bg-neutral-50 dark:bg-neutral-900;
|
||||
}
|
||||
@utility bg-surface {
|
||||
@apply bg-white dark:bg-neutral-800;
|
||||
}
|
||||
@utility bg-surface-elevated {
|
||||
@apply bg-white dark:bg-neutral-700;
|
||||
}
|
||||
@utility bg-overlay {
|
||||
@apply bg-black/50 dark:bg-black/70;
|
||||
}
|
||||
|
||||
/* Status Backgrounds */
|
||||
/* Background */
|
||||
@utility bg-status-success {
|
||||
@apply bg-success-100 dark:bg-success-900/30;
|
||||
}
|
||||
@@ -142,32 +183,7 @@
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
}
|
||||
|
||||
/* Interactive Backgrounds */
|
||||
@utility bg-interactive {
|
||||
@apply bg-neutral-50 dark:bg-neutral-700;
|
||||
}
|
||||
@utility bg-interactive-hover {
|
||||
@apply hover:bg-neutral-100 dark:hover:bg-neutral-600;
|
||||
}
|
||||
@utility bg-interactive-active {
|
||||
@apply bg-neutral-200 dark:bg-neutral-600;
|
||||
}
|
||||
|
||||
/* Border Colors - Semantic */
|
||||
@utility border-default {
|
||||
@apply border-neutral-200 dark:border-neutral-700;
|
||||
}
|
||||
@utility border-subtle {
|
||||
@apply border-neutral-100 dark:border-neutral-800;
|
||||
}
|
||||
@utility border-accent {
|
||||
@apply border-primary-500 dark:border-primary-400;
|
||||
}
|
||||
@utility border-emphasis {
|
||||
@apply border-primary-600 dark:border-primary-300;
|
||||
}
|
||||
|
||||
/* Status Borders */
|
||||
/* Borders */
|
||||
@utility border-status-success {
|
||||
@apply border-success-600;
|
||||
}
|
||||
@@ -181,56 +197,35 @@
|
||||
@apply border-primary-600;
|
||||
}
|
||||
|
||||
/* Focus States */
|
||||
@utility focus-accent {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500;
|
||||
/* ---------------------------------- */
|
||||
/* Interactive */
|
||||
/* ---------------------------------- */
|
||||
|
||||
@utility bg-interactive {
|
||||
@apply bg-neutral-50 dark:bg-neutral-800;
|
||||
}
|
||||
@utility focus-danger {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-danger-500;
|
||||
@utility bg-interactive-hover {
|
||||
@apply hover:bg-neutral-100 dark:hover:bg-neutral-700;
|
||||
}
|
||||
@utility interactive-disabled {
|
||||
@apply disabled:opacity-50 disabled:bg-disabled disabled:hover:bg-disabled disabled:text-primary disabled:border-default disabled:cursor-default;
|
||||
}
|
||||
|
||||
/* Tab States */
|
||||
@utility tab-active {
|
||||
@apply text-brand font-semibold border-b-3 border-accent;
|
||||
}
|
||||
@utility tab-inactive {
|
||||
@apply text-secondary bg-interactive-hover border-b-3 border-transparent hover:border-default;
|
||||
}
|
||||
|
||||
/* Selection States */
|
||||
@utility selected-accent {
|
||||
@apply border-accent bg-primary-600;
|
||||
}
|
||||
@utility unselected-neutral {
|
||||
@apply border-default bg-surface;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 1. Semantic Animation Utilities - Motion System */
|
||||
/* -------------------------------------------------- */
|
||||
/* ========================================================================== */
|
||||
/* 1. Semantic Animation Utilities */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* Hover Effects */
|
||||
@utility hover-lift {
|
||||
@apply hover:-translate-y-1 hover:shadow-elevation hover:dark:shadow-elevation-dark;
|
||||
}
|
||||
@utility hover-subtle {
|
||||
@apply hover:bg-interactive-hover;
|
||||
@apply bg-interactive-hover;
|
||||
}
|
||||
@utility hover-scale {
|
||||
@apply hover:scale-105;
|
||||
}
|
||||
|
||||
/* Transition Speeds */
|
||||
@utility transition-fast {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
@utility transition-smooth {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@utility transition-slow {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Slide Animations */
|
||||
@utility slide-in-right {
|
||||
@apply transform transition-transform duration-500 ease-out translate-x-full;
|
||||
@@ -238,18 +233,6 @@
|
||||
@utility slide-in-right-visible {
|
||||
@apply translate-x-0;
|
||||
}
|
||||
@utility slide-in-left {
|
||||
@apply transform transition-transform duration-300 ease-out -translate-x-full;
|
||||
}
|
||||
@utility slide-in-left-visible {
|
||||
@apply translate-x-0;
|
||||
}
|
||||
@utility slide-in-up {
|
||||
@apply transform transition-transform duration-300 ease-out translate-y-full;
|
||||
}
|
||||
@utility slide-in-up-visible {
|
||||
@apply translate-y-0;
|
||||
}
|
||||
|
||||
/* Fade Animations */
|
||||
@utility fade-in {
|
||||
@@ -267,10 +250,8 @@
|
||||
|
||||
/* Interactive States */
|
||||
@utility interactive-element {
|
||||
@apply transition-smooth cursor-pointer focus-accent;
|
||||
}
|
||||
@utility card-interactive {
|
||||
@apply interactive-element hover-lift;
|
||||
transition: all 0.2s ease;
|
||||
@apply cursor-pointer focus-accent;
|
||||
}
|
||||
|
||||
/* Focus Animations */
|
||||
@@ -281,9 +262,22 @@
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-danger-500 focus:ring-offset-2 transition-all duration-200;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 2. Base layer: resets, defaults, theming, scrollbar */
|
||||
/* -------------------------------------------------- */
|
||||
/* ========================================================================== */
|
||||
/* 2. Utilities */
|
||||
/* ========================================================================== */
|
||||
@utility flex-center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
@utility flex-center-between {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
@utility inline-flex-center {
|
||||
@apply inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 3. Base layer: resets, defaults, theming, scrollbar */
|
||||
/* ========================================================================== */
|
||||
@layer base {
|
||||
* {
|
||||
@apply transition duration-200 ease-in-out;
|
||||
@@ -331,7 +325,7 @@
|
||||
}
|
||||
|
||||
select {
|
||||
@apply appearance-none bg-surface border border-default cursor-pointer focus-accent px-3 py-2 rounded-lg w-full;
|
||||
@apply appearance-none bg-surface bg-interactive-hover border border-default cursor-pointer focus-accent px-3 py-2 rounded-lg w-full;
|
||||
}
|
||||
input {
|
||||
@apply bg-surface border border-default focus-accent px-3 py-2 rounded-lg w-full;
|
||||
@@ -350,53 +344,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 3. Components layer: reusable patterns */
|
||||
/* -------------------------------------------------- */
|
||||
/* ========================================================================== */
|
||||
/* 4. Components layer: reusable patterns */
|
||||
/* ========================================================================== */
|
||||
|
||||
@utility btn {
|
||||
@apply inline-flex items-center justify-center font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer px-4 py-2 disabled:opacity-50 disabled:bg-neutral-500 disabled:hover:bg-neutral-600 text-white;
|
||||
@apply inline-flex-center bg-interactive-hover font-medium rounded-lg focus:ring-2 cursor-pointer px-4 py-2 border interactive-disabled border-default text-primary;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-500 hover:bg-primary-600 focus:ring-primary-300;
|
||||
@apply btn text-white bg-primary-500 border-primary-600 hover:bg-primary-600 focus:ring-primary-300;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-500 hover:bg-secondary-600 focus:ring-secondary-300;
|
||||
@apply btn text-white bg-secondary-500 border-secondary-600 hover:bg-secondary-600 focus:ring-secondary-300;
|
||||
}
|
||||
.btn-success {
|
||||
@apply btn bg-success-500 hover:bg-success-600 focus:ring-success-300;
|
||||
@apply btn text-white bg-success-500 border-success-600 hover:bg-success-600 focus:ring-success-300;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-500 hover:bg-danger-600 focus:ring-danger-300;
|
||||
@apply btn text-white bg-danger-500 border-danger-600 hover:bg-danger-600 focus:ring-danger-300;
|
||||
}
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-500 hover:bg-warning-600 focus:ring-warning-300;
|
||||
@apply btn text-white bg-warning-500 border-warning-600 hover:bg-warning-600 focus:ring-warning-300;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply bg-surface border border-default rounded-xl shadow-soft dark:shadow-soft-dark p-4 border-1 w-full;
|
||||
}
|
||||
|
||||
/* Custom styled select with arrow */
|
||||
.icon-button {
|
||||
@apply inline-flex items-center justify-center w-9 h-9 text-sm rounded-lg bg-surface bg-interactive-hover border border-default focus-accent;
|
||||
.card-interactive {
|
||||
@apply interactive-element hover-lift;
|
||||
}
|
||||
|
||||
.voucher-code {
|
||||
@apply font-bold text-brand font-mono tracking-wider;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 4. Utilities layer: small helpers */
|
||||
/* -------------------------------------------------- */
|
||||
@layer utilities {
|
||||
/* Flex center shortcuts */
|
||||
.flex-center {
|
||||
@apply flex items-center justify-center;
|
||||
.tab-active {
|
||||
@apply text-brand font-semibold border-b-3 border-accent;
|
||||
}
|
||||
.tab-inactive {
|
||||
@apply text-secondary bg-interactive-hover border-b-3 border-transparent hover:border-intense;
|
||||
}
|
||||
.sticky-tabs {
|
||||
top: var(--header-height);
|
||||
}
|
||||
}
|
||||
|
@@ -18,6 +18,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Load runtime config */}
|
||||
<script src="/runtime-config.js"></script>
|
||||
</head>
|
||||
<body className={`antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
@@ -1,16 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
|
||||
import WifiQrModal from "@/components/modals/WifiQrModal";
|
||||
import { generateWifiConfig, WifiConfig } from "@/utils/wifi";
|
||||
|
||||
export default function Header() {
|
||||
const [showWifi, setShowWifi] = useState(false);
|
||||
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial height and update on resize
|
||||
function updateHeaderHeight() {
|
||||
if (headerRef.current) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--header-height",
|
||||
`${headerRef.current.offsetHeight}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateHeaderHeight();
|
||||
window.addEventListener("resize", updateHeaderHeight);
|
||||
return () => window.removeEventListener("resize", updateHeaderHeight);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const config: WifiConfig | null = (() => {
|
||||
try {
|
||||
return generateWifiConfig();
|
||||
} catch (e) {
|
||||
console.warn(`Could not generate WiFi configuration: ${e}`);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
setWifiConfig(config);
|
||||
}, [generateWifiConfig]);
|
||||
|
||||
return (
|
||||
<header className="bg-surface border-b border-default sticky top-0 z-7000">
|
||||
<div className="max-w-95/100 mx-auto flex items-center justify-between px-4 py-4">
|
||||
<header
|
||||
ref={headerRef}
|
||||
className="bg-surface border-b border-default sticky top-0 z-7000"
|
||||
>
|
||||
<div className="max-w-95/100 mx-auto flex-center-between px-4 py-4">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-brand">
|
||||
UniFi Voucher Manager
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowWifi(true)}
|
||||
className="btn p-1"
|
||||
disabled={!wifiConfig}
|
||||
aria-label="Open Wi‑Fi QR code"
|
||||
title="Open Wi‑Fi QR code"
|
||||
>
|
||||
<img
|
||||
src="/qr.svg"
|
||||
width={45}
|
||||
height={45}
|
||||
className="dark:invert"
|
||||
alt="QR code icon"
|
||||
/>
|
||||
</button>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{showWifi && wifiConfig && (
|
||||
<WifiQrModal
|
||||
wifiConfig={wifiConfig}
|
||||
onClose={() => setShowWifi(false)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
@@ -1,17 +1,12 @@
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import {
|
||||
formatCode,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatGuestUsage,
|
||||
} from "@/utils/format";
|
||||
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
|
||||
import { memo } from "react";
|
||||
|
||||
type Props = {
|
||||
voucher: Voucher;
|
||||
selected: boolean;
|
||||
editMode: boolean;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||
@@ -29,7 +24,7 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||
{editMode && (
|
||||
<div className="absolute top-3 right-3 z-1000">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-smooth
|
||||
className={`w-6 h-6 rounded-full border-2 flex-center
|
||||
${selected ? "selected-accent" : "unselected-neutral"}`}
|
||||
>
|
||||
{selected && <div className="w-3 h-3 bg-white rounded-full" />}
|
||||
@@ -62,20 +57,18 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||
{voucher.activatedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>First Used:</span>
|
||||
<span className="text-xs">{formatDate(voucher.activatedAt)}</span>
|
||||
<span className="text-xs">{voucher.activatedAt}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex-center-between">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`}
|
||||
>
|
||||
{voucher.expired ? "Expired" : "Active"}
|
||||
</span>
|
||||
{voucher.expiresAt && (
|
||||
<span className="text-xs">
|
||||
Expires: {formatDate(voucher.expiresAt)}
|
||||
</span>
|
||||
<span className="text-xs">Expires: {voucher.expiresAt}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { ReactNode, useEffect, RefObject } from "react";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
/** Extra classes for the content container */
|
||||
contentClassName?: string;
|
||||
ref?: RefObject<HTMLDivElement | null>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Modal({
|
||||
onClose,
|
||||
contentClassName = "",
|
||||
ref,
|
||||
children,
|
||||
}: Props) {
|
||||
// lock scroll + handle Escape
|
||||
@@ -41,6 +43,7 @@ export default function Modal({
|
||||
>
|
||||
<div
|
||||
className={`bg-surface border border-default flex flex-col max-h-9/10 max-w-lg overflow-hidden relative rounded-xl shadow-2xl w-full ${contentClassName}`}
|
||||
ref={ref}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
@@ -6,7 +6,6 @@ import { api } from "@/utils/api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatGuestUsage,
|
||||
formatSpeed,
|
||||
@@ -64,13 +63,11 @@ export default function VoucherModal({ voucher, onClose }: Props) {
|
||||
[
|
||||
["Status", details.expired ? "Expired" : "Active"],
|
||||
["Name", details.name || "No note"],
|
||||
["Created", formatDate(details.createdAt)],
|
||||
["Created", details.createdAt],
|
||||
...(details.activatedAt
|
||||
? [["Activated", formatDate(details.activatedAt)]]
|
||||
: []),
|
||||
...(details.expiresAt
|
||||
? [["Expires", formatDate(details.expiresAt)]]
|
||||
? [["Activated", details.activatedAt]]
|
||||
: []),
|
||||
...(details.expiresAt ? [["Expires", details.expiresAt]] : []),
|
||||
["Duration", formatDuration(details.timeLimitMinutes)],
|
||||
[
|
||||
"Guest Usage",
|
||||
@@ -92,7 +89,7 @@ export default function VoucherModal({ voucher, onClose }: Props) {
|
||||
).map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex justify-between items-center p-4 bg-interactive border border-subtle rounded-xl space-x-4"
|
||||
className="flex-center-between p-4 bg-interactive border border-subtle rounded-xl space-x-4"
|
||||
>
|
||||
<span className="font-semibold text-primary">{label}:</span>
|
||||
<span className="text-secondary">{value}</span>
|
||||
|
74
frontend/src/components/modals/WifiQrModal.tsx
Normal file
74
frontend/src/components/modals/WifiQrModal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useMemo } from "react";
|
||||
import Modal from "@/components/modals/Modal";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { generateWiFiQRString, WifiConfig } from "@/utils/wifi";
|
||||
|
||||
type Props = {
|
||||
wifiConfig: WifiConfig;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function WifiQrModal({ wifiConfig, onClose }: Props) {
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const [qrSize, setQrSize] = useState(220);
|
||||
const wifiString = useMemo(
|
||||
() => wifiConfig && generateWiFiQRString(wifiConfig),
|
||||
[wifiConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
if (modalRef.current) {
|
||||
// Make sure the QR code scales with the size of the modal
|
||||
const modalWidth = modalRef.current.offsetWidth;
|
||||
const modalHeight = modalRef.current.offsetHeight;
|
||||
const sizeFromWidth = modalWidth * 0.8;
|
||||
const sizeFromHeight = modalHeight * 0.8;
|
||||
setQrSize(Math.floor(Math.min(sizeFromWidth, sizeFromHeight)));
|
||||
}
|
||||
}
|
||||
updateSize();
|
||||
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal ref={modalRef} onClose={onClose} contentClassName="max-w-md">
|
||||
<div className="flex-center flex-col gap-4">
|
||||
<h2 className="text-2xl font-bold text-primary text-center">
|
||||
Wi‑Fi QR Code
|
||||
</h2>
|
||||
{wifiString ? (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
value={wifiString}
|
||||
size={qrSize}
|
||||
level="H"
|
||||
bgColor="transparent"
|
||||
fgColor="currentColor"
|
||||
marginSize={4}
|
||||
title="Wi-Fi Access QR Code"
|
||||
imageSettings={{
|
||||
src: "/unifi.svg",
|
||||
height: qrSize / 4,
|
||||
width: qrSize / 4,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted">
|
||||
Scan this QR code to join the network:{" "}
|
||||
<strong>{wifiConfig.ssid}</strong>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted text-center">
|
||||
No Wi‑Fi credentials configured.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -35,6 +35,8 @@ export default function NotificationItem({ id, message, type, onDone }: Props) {
|
||||
return "border-status-success text-status-success";
|
||||
case "error":
|
||||
return "border-status-danger text-status-danger";
|
||||
case "warning":
|
||||
return "border-status-warning text-status-warning";
|
||||
default:
|
||||
return "border-status-info text-status-info";
|
||||
}
|
||||
|
@@ -43,7 +43,7 @@ export default function Tabs() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-surface border-b border-default flex sticky top-16 z-2000 shadow-soft dark:shadow-soft-dark">
|
||||
<nav className="bg-surface border-b border-default flex sticky sticky-tabs z-2000 shadow-soft dark:shadow-soft-dark">
|
||||
{enabledTabs.map((tabConfig) => (
|
||||
<button
|
||||
key={tabConfig.id}
|
||||
|
@@ -2,8 +2,13 @@
|
||||
|
||||
import { notify } from "@/utils/notifications";
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
import VoucherCard from "../VoucherCard";
|
||||
import VoucherModal from "@/components/modals/VoucherModal";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Voucher } from "@/types/voucher";
|
||||
|
||||
export default function TestTab() {
|
||||
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
|
||||
const sendInfo = () => notify("This is an info notification", "info");
|
||||
const sendSuccess = () => notify("Operation succeeded!", "success");
|
||||
const sendError = () => notify("Something went wrong!", "error");
|
||||
@@ -13,31 +18,107 @@ export default function TestTab() {
|
||||
setTimeout(() => notify("Third message", "error"), 1000);
|
||||
};
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setViewVoucher(null);
|
||||
}, []);
|
||||
|
||||
const v: Voucher = {
|
||||
id: "test-voucher",
|
||||
createdAt: "2025-12-31",
|
||||
name: "Test Voucher",
|
||||
code: "TEST123",
|
||||
authorizedGuestCount: 0,
|
||||
authorizedGuestLimit: null,
|
||||
expired: false,
|
||||
timeLimitMinutes: 1440,
|
||||
activatedAt: null,
|
||||
expiresAt: "2025-12-31",
|
||||
dataUsageLimitMBytes: null,
|
||||
rxRateLimitKbps: null,
|
||||
txRateLimitKbps: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-center flex-col space-y-6">
|
||||
<div className="card max-w-lg space-y-4">
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Notification Tester
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onClick={sendInfo} className="btn-primary">
|
||||
Send Info
|
||||
</button>
|
||||
<button onClick={sendSuccess} className="btn-success">
|
||||
Send Success
|
||||
</button>
|
||||
<button onClick={sendError} className="btn-danger">
|
||||
Send Error
|
||||
</button>
|
||||
<button onClick={sendMultiple} className="btn-primary">
|
||||
Send Multiple
|
||||
</button>
|
||||
<>
|
||||
<div className="flex-center flex-col space-y-6">
|
||||
<div className="card max-w-lg space-y-4">
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Notification Tester
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onClick={sendInfo} className="btn-primary">
|
||||
Send Info
|
||||
</button>
|
||||
<button onClick={sendSuccess} className="btn-success">
|
||||
Send Success
|
||||
</button>
|
||||
<button onClick={sendError} className="btn-danger">
|
||||
Send Error
|
||||
</button>
|
||||
<button onClick={sendMultiple} className="btn-primary">
|
||||
Send Multiple
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card max-w-lg">
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Spinner Example
|
||||
</h2>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div className="flex-center flex-row gap-4 w-full">
|
||||
<VoucherCard
|
||||
key={123}
|
||||
voucher={{
|
||||
id: "abc123",
|
||||
createdAt: "2025-12-31",
|
||||
name: "test voucher",
|
||||
code: "1234567890",
|
||||
authorizedGuestCount: 0,
|
||||
expired: false,
|
||||
timeLimitMinutes: 1440,
|
||||
}}
|
||||
editMode={false}
|
||||
selected={false}
|
||||
onClick={() => setViewVoucher(v)}
|
||||
/>
|
||||
<VoucherCard
|
||||
key={456}
|
||||
voucher={{
|
||||
id: "abc123",
|
||||
createdAt: "2025-12-31",
|
||||
name: "test voucher",
|
||||
code: "1234567890",
|
||||
authorizedGuestCount: 0,
|
||||
expired: false,
|
||||
timeLimitMinutes: 1440,
|
||||
}}
|
||||
editMode={true}
|
||||
selected={true}
|
||||
onClick={() => setViewVoucher(v)}
|
||||
/>
|
||||
<VoucherCard
|
||||
key={789}
|
||||
voucher={{
|
||||
id: "abc123",
|
||||
createdAt: "2025-12-31",
|
||||
name: "test voucher",
|
||||
code: "1234567890",
|
||||
authorizedGuestCount: 1,
|
||||
expired: true,
|
||||
timeLimitMinutes: 1440,
|
||||
expiresAt: "2025-12-31",
|
||||
}}
|
||||
editMode={true}
|
||||
selected={false}
|
||||
onClick={() => setViewVoucher(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card max-w-lg">
|
||||
<h2 className="text-lg font-semibold text-primary">Spinner Example</h2>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
{viewVoucher && (
|
||||
<VoucherModal voucher={viewVoucher} onClose={closeModal} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
7
frontend/src/types/runtime.ts
Normal file
7
frontend/src/types/runtime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__RUNTIME_CONFIG__?: { [key: string]: string | undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
@@ -2,10 +2,6 @@ export function formatCode(code: string) {
|
||||
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
|
||||
}
|
||||
|
||||
export function formatDate(d: string) {
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDuration(m: number | null | undefined) {
|
||||
if (!m) return "Unlimited";
|
||||
const days = Math.floor(m / 1440),
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export type NotificationType = "success" | "error" | "info";
|
||||
export type NotificationType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface NotificationPayload {
|
||||
id: string;
|
||||
|
4
frontend/src/utils/runtimeConfig.ts
Normal file
4
frontend/src/utils/runtimeConfig.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const getRuntimeConfig = (): Record<string, string | undefined> => {
|
||||
if (typeof window === "undefined") return {};
|
||||
return window.__RUNTIME_CONFIG__ ?? {};
|
||||
};
|
125
frontend/src/utils/wifi.ts
Normal file
125
frontend/src/utils/wifi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { getRuntimeConfig } from "@/utils/runtimeConfig";
|
||||
|
||||
// Derive the type from the array
|
||||
const validWifiTypes = ["WPA", "WEP", "nopass"] as const;
|
||||
type WifiType = (typeof validWifiTypes)[number];
|
||||
|
||||
export interface WifiConfig {
|
||||
ssid: string;
|
||||
password: string;
|
||||
type: WifiType;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export function generateWifiConfig(): WifiConfig {
|
||||
const {
|
||||
WIFI_SSID: ssid,
|
||||
WIFI_PASSWORD: password,
|
||||
WIFI_TYPE: type,
|
||||
WIFI_HIDDEN: hidden,
|
||||
} = getRuntimeConfig();
|
||||
|
||||
if (ssid == null) {
|
||||
throw "No SSID provided, use the environment variable WIFI_SSID to set one";
|
||||
}
|
||||
|
||||
if (password == null) {
|
||||
throw 'No password provided, use the environment variable WIFI_PASSWORD to set one. If your network does not have a password, use WIFI_PASSWORD=""';
|
||||
}
|
||||
|
||||
let type_parsed: WifiType;
|
||||
if (type == null) {
|
||||
if (password) {
|
||||
type_parsed = "WPA";
|
||||
console.log(
|
||||
`WiFi Configuration: type not provided, but password provided. Defaulting to 'type=${type_parsed}' `,
|
||||
);
|
||||
} else {
|
||||
type_parsed = "nopass";
|
||||
console.log(
|
||||
`WiFi Configuration: type and password not provided. Defaulting to 'type=${type_parsed}' `,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
switch (type.trim().toLowerCase()) {
|
||||
case "wpa":
|
||||
type_parsed = "WPA";
|
||||
break;
|
||||
case "wep":
|
||||
type_parsed = "WEP";
|
||||
break;
|
||||
case "nopass":
|
||||
type_parsed = "nopass";
|
||||
break;
|
||||
default:
|
||||
throw `Invalid WiFi type provided: ${type}. Valid types: ${validWifiTypes.join(
|
||||
" | ",
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (password && type_parsed === "nopass") {
|
||||
throw "Incoherent WiFi configuration: password provided, but type set to 'nopass'";
|
||||
} else if (!password && type_parsed !== "nopass") {
|
||||
throw "Incoherent WiFi configuration: password not provided, but type implies a password";
|
||||
}
|
||||
|
||||
let hidden_parsed: boolean;
|
||||
if (hidden == null) {
|
||||
hidden_parsed = false;
|
||||
console.log(
|
||||
`WiFi Configuration: hidden state not provided, defaulting to 'hidden=${hidden_parsed}' `,
|
||||
);
|
||||
} else {
|
||||
switch (hidden.trim().toLowerCase()) {
|
||||
case "true":
|
||||
case "1":
|
||||
hidden_parsed = true;
|
||||
break;
|
||||
case "false":
|
||||
case "0":
|
||||
hidden_parsed = false;
|
||||
break;
|
||||
default:
|
||||
throw `Invalid WiFi hidden state provided: ${hidden}`;
|
||||
}
|
||||
}
|
||||
|
||||
const config: WifiConfig = {
|
||||
ssid: ssid,
|
||||
password: password,
|
||||
type: type_parsed,
|
||||
hidden: hidden_parsed,
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
export function generateWiFiQRString(config: WifiConfig): string {
|
||||
const { ssid, password, type, hidden = false } = config;
|
||||
|
||||
const encodedSSID = escapeWiFiString(ssid);
|
||||
const encodedPassword = escapeWiFiString(password);
|
||||
|
||||
// Format: WIFI:[T:security;][S:ssid;][P:password;][H:hidden;];
|
||||
let qrString = "WIFI:";
|
||||
|
||||
if (type !== "nopass") {
|
||||
qrString += `T:${type};`;
|
||||
}
|
||||
|
||||
qrString += `S:${encodedSSID};`;
|
||||
|
||||
if (type !== "nopass" && password) {
|
||||
qrString += `P:${encodedPassword};`;
|
||||
}
|
||||
|
||||
qrString += `H:${hidden};`;
|
||||
|
||||
qrString += ";";
|
||||
|
||||
return qrString;
|
||||
}
|
||||
|
||||
function escapeWiFiString(str: string): string {
|
||||
return str.replace(/([\\;:,"])/g, "\\$1");
|
||||
}
|
25
scripts/entrypoint.sh
Normal file
25
scripts/entrypoint.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -e
|
||||
|
||||
# Set variables accessible by the frontend
|
||||
mkdir -p /app/frontend/public
|
||||
|
||||
# Build runtime-config.js containing only env vars that are defined and non-empty.
|
||||
node - <<'NODE'
|
||||
const fs = require('fs');
|
||||
const outPath = '/app/frontend/public/runtime-config.js';
|
||||
const keys = ['WIFI_SSID','WIFI_PASSWORD','WIFI_TYPE','WIFI_HIDDEN'];
|
||||
const cfg = {};
|
||||
for (const k of keys) {
|
||||
const v = process.env[k];
|
||||
if (v !== undefined) {
|
||||
cfg[k] = v;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(outPath, 'window.__RUNTIME_CONFIG__ = ' + JSON.stringify(cfg) + ';', 'utf8');
|
||||
console.log('WROTE', outPath, 'keys=', Object.keys(cfg));
|
||||
NODE
|
||||
|
||||
# exec the original command
|
||||
exec "$@"
|
@@ -17,8 +17,7 @@ sleep 3
|
||||
# Start frontend in foreground
|
||||
echo "Starting frontend..."
|
||||
NEXT_TELEMETRY_DISABLED="1" NODE_ENV="production" \
|
||||
HOSTNAME=${FRONTEND_BIND_HOST} PORT="${FRONTEND_BIND_PORT}" \
|
||||
UNIFI_CONTROLLER_URL="" UNIFI_API_KEY="" UNIFI_SITE_ID="" \
|
||||
HOSTNAME="${FRONTEND_BIND_HOST}" PORT="${FRONTEND_BIND_PORT}" \
|
||||
node ./frontend/server.js &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
|
Reference in New Issue
Block a user