feat: added QR code modal

This commit is contained in:
etiennecollin
2025-08-13 20:10:00 +02:00
parent b61a61c93c
commit 5366b46628
6 changed files with 251 additions and 8 deletions

View File

@@ -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",

View File

@@ -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
View 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

View File

@@ -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 WiFi QR code"
title="Open WiFi QR code"
>
{/* TODO: Make content a small QR code SVG */}
QR
</button>
<ThemeSwitcher />
</div>
</div>
{showWifi && wifiConfig && (
<WifiQrModal
wifiConfig={wifiConfig}
onClose={() => setShowWifi(false)}
/>
)}
</header>
);
}

View 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">
WiFi 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 WiFi credentials configured.
</p>
)}
</div>
</Modal>
);
}

117
frontend/src/utils/wifi.ts Normal file
View 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");
}