mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 08:12:15 +00:00
feat: added QR code modal
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
|
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 |
@@ -1,16 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
|
||||
import WifiQrModal from "@/components/modals/WifiQrModal";
|
||||
import { generateWifiConfig, WifiConfig } from "@/utils/wifi";
|
||||
|
||||
const wifiConfig: WifiConfig | null = (() => {
|
||||
try {
|
||||
return generateWifiConfig();
|
||||
} catch (e) {
|
||||
console.warn(`Could not generate WiFi configuration: ${e}`);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
export default function Header() {
|
||||
const [showWifi, setShowWifi] = useState(false);
|
||||
|
||||
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">
|
||||
<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"
|
||||
disabled={!wifiConfig}
|
||||
aria-label="Open Wi‑Fi QR code"
|
||||
title="Open Wi‑Fi QR code"
|
||||
>
|
||||
{/* TODO: Make content a small QR code SVG */}
|
||||
QR
|
||||
</button>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{showWifi && wifiConfig && (
|
||||
<WifiQrModal
|
||||
wifiConfig={wifiConfig}
|
||||
onClose={() => setShowWifi(false)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
72
frontend/src/components/modals/WifiQrModal.tsx
Normal file
72
frontend/src/components/modals/WifiQrModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } 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 = wifiConfig && generateWiFiQRString(wifiConfig);
|
||||
console.log(wifiString);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
117
frontend/src/utils/wifi.ts
Normal file
117
frontend/src/utils/wifi.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
type WifiType = "WPA" | "WEP" | "nopass";
|
||||
|
||||
export interface WifiConfig {
|
||||
ssid: string;
|
||||
password: string;
|
||||
type: WifiType;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export function generateWifiConfig(): WifiConfig {
|
||||
const ssid = process.env.NEXT_WIFI_SSID;
|
||||
const password = process.env.NEXT_WIFI_PASSWORD;
|
||||
const type = process.env.NEXT_WIFI_TYPE;
|
||||
const hidden = process.env.NEXT_WIFI_HIDDEN;
|
||||
|
||||
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:
|
||||
// TODO: Find how to print a type
|
||||
throw `Invalid WiFi type provided: ${type}. Valid types: "WPA" | "WEP" | "nopass"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (password && type_parsed === "nopass") {
|
||||
throw "Incoherent WiFi configuration: password provided, but type set to 'nopass'";
|
||||
} else if (!password && type_parsed !== "nopass") {
|
||||
// TODO: This is true for WPA, but check for WEP
|
||||
throw "Incoherent WiFi configuration: password not provided, but type implies a password";
|
||||
}
|
||||
|
||||
let hidden_parsed: boolean;
|
||||
if (hidden == null) {
|
||||
hidden_parsed = true;
|
||||
console.log(
|
||||
`WiFi Configuration: hidden state not provided, defaulting to 'hidden=${hidden_parsed}' `,
|
||||
);
|
||||
} else {
|
||||
switch (hidden.trim().toLowerCase()) {
|
||||
case "true":
|
||||
hidden_parsed = true;
|
||||
break;
|
||||
case "false":
|
||||
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");
|
||||
}
|
Reference in New Issue
Block a user