Skip to content

Commit 7bbdb3a

Browse files
committed
Add configurable QR code base URL for reverse proxy setups (#52)
Users behind nginx reverse proxies or custom domains can now set a base URL override in Settings for QR code labels and NFC tags. When empty, current behavior is preserved (uses browser URL).
1 parent 3f3bbe5 commit 7bbdb3a

7 files changed

Lines changed: 102 additions & 10 deletions

File tree

app/src/app/api/settings/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export async function GET() {
4545
}
4646
}
4747

48+
// Fetch optional QR base URL override
49+
const qrBaseUrlSetting = await prisma.settings.findUnique({ where: { key: 'qr_base_url' } });
50+
4851
return NextResponse.json({
4952
embeddedMode: false,
5053
addonMode: true,
@@ -57,6 +60,7 @@ export async function GET() {
5760
url: activeSpoolmanConnection.url,
5861
connected: true,
5962
} : null,
63+
qrBaseUrl: qrBaseUrlSetting?.value || '',
6064
});
6165
}
6266

@@ -234,6 +238,9 @@ export async function GET() {
234238
}
235239
// else: null — HA hasn't been set up yet (first startup)
236240

241+
// Fetch optional QR base URL override
242+
const qrBaseUrlSetting = await prisma.settings.findUnique({ where: { key: 'qr_base_url' } });
243+
237244
return NextResponse.json({
238245
embeddedMode,
239246
addonMode: false,
@@ -242,6 +249,7 @@ export async function GET() {
242249
url: spoolmanConnection.url,
243250
connected: true,
244251
} : null,
252+
qrBaseUrl: qrBaseUrlSetting?.value || '',
245253
});
246254
} catch (error) {
247255
console.error('Error fetching settings:', error);
@@ -315,6 +323,21 @@ export async function POST(request: NextRequest) {
315323
return NextResponse.json({ success: true });
316324
}
317325

326+
if (type === 'qr_base_url') {
327+
// Save QR code base URL override
328+
const qrBaseUrl = (url || '').trim().replace(/\/+$/, '');
329+
if (qrBaseUrl) {
330+
await prisma.settings.upsert({
331+
where: { key: 'qr_base_url' },
332+
create: { key: 'qr_base_url', value: qrBaseUrl },
333+
update: { value: qrBaseUrl },
334+
});
335+
} else {
336+
await prisma.settings.deleteMany({ where: { key: 'qr_base_url' } });
337+
}
338+
return NextResponse.json({ success: true });
339+
}
340+
318341
if (type === 'spoolman') {
319342
// Validate Spoolman connection
320343
const client = new SpoolmanClient(url);

app/src/app/scan/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function ScanPageContent() {
2121
const [manualBarcode, setManualBarcode] = useState('');
2222
const [spoolsLoading, setSpoolsLoading] = useState(true);
2323
const [directAccessPort, setDirectAccessPort] = useState<number | undefined>(undefined);
24+
const [qrBaseUrl, setQrBaseUrl] = useState<string | undefined>(undefined);
2425

2526
const handleScan = useCallback(async (scannedData: string) => {
2627
setLoading(true);
@@ -110,6 +111,9 @@ function ScanPageContent() {
110111
if (data.directAccessPort) {
111112
setDirectAccessPort(data.directAccessPort);
112113
}
114+
if (data.qrBaseUrl) {
115+
setQrBaseUrl(data.qrBaseUrl);
116+
}
113117
})
114118
.catch(() => {});
115119
}, []);
@@ -193,7 +197,7 @@ function ScanPageContent() {
193197
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
194198
</div>
195199
) : allSpools.length > 0 ? (
196-
<QRCodeGenerator spools={allSpools} directAccessPort={directAccessPort} />
200+
<QRCodeGenerator spools={allSpools} directAccessPort={directAccessPort} qrBaseUrl={qrBaseUrl} />
197201
) : (
198202
<p className="text-sm text-muted-foreground text-center py-4">
199203
No spools found. Add spools to Spoolman to generate QR labels.
@@ -216,7 +220,7 @@ function ScanPageContent() {
216220
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
217221
</div>
218222
) : allSpools.length > 0 ? (
219-
<NFCWriter spools={allSpools} directAccessPort={directAccessPort} />
223+
<NFCWriter spools={allSpools} directAccessPort={directAccessPort} qrBaseUrl={qrBaseUrl} />
220224
) : (
221225
<p className="text-sm text-muted-foreground text-center py-4">
222226
No spools found. Add spools to Spoolman to write NFC tags.

app/src/app/settings/page.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ function SettingsContent() {
7878
const [enabledFilters, setEnabledFilters] = useState<string[]>([]);
7979
const [savingFilters, setSavingFilters] = useState(false);
8080

81+
// QR base URL state
82+
const [qrBaseUrl, setQrBaseUrl] = useState('');
83+
const [savingQrUrl, setSavingQrUrl] = useState(false);
84+
8185
// Alert configuration states
8286
const [alertConfig, setAlertConfig] = useState<AlertConfig>({
8387
enabled: false,
@@ -151,6 +155,9 @@ function SettingsContent() {
151155
if (data.spoolman) {
152156
setSpoolmanUrl(data.spoolman.url);
153157
}
158+
if (data.qrBaseUrl !== undefined) {
159+
setQrBaseUrl(data.qrBaseUrl);
160+
}
154161
} catch {
155162
toast.error('Failed to load settings');
156163
} finally {
@@ -1163,6 +1170,55 @@ function SettingsContent() {
11631170
</>
11641171
)}
11651172

1173+
{/* QR Code Base URL */}
1174+
<Card>
1175+
<CardHeader>
1176+
<CardTitle>QR Code / NFC URL</CardTitle>
1177+
<CardDescription>
1178+
Override the base URL used in generated QR code labels and NFC tags.
1179+
Leave empty to use the current browser URL automatically.
1180+
</CardDescription>
1181+
</CardHeader>
1182+
<CardContent className="space-y-3">
1183+
<div className="space-y-2">
1184+
<Label htmlFor="qrBaseUrl">Base URL</Label>
1185+
<div className="flex gap-2">
1186+
<Input
1187+
id="qrBaseUrl"
1188+
value={qrBaseUrl}
1189+
onChange={(e) => setQrBaseUrl(e.target.value)}
1190+
placeholder="e.g., http://192.168.1.100:3000"
1191+
/>
1192+
<Button
1193+
onClick={async () => {
1194+
setSavingQrUrl(true);
1195+
try {
1196+
const res = await fetch('/api/settings', {
1197+
method: 'POST',
1198+
headers: { 'Content-Type': 'application/json' },
1199+
body: JSON.stringify({ type: 'qr_base_url', url: qrBaseUrl }),
1200+
});
1201+
if (!res.ok) throw new Error();
1202+
toast.success(qrBaseUrl.trim() ? 'QR base URL saved' : 'QR base URL cleared');
1203+
} catch {
1204+
toast.error('Failed to save QR base URL');
1205+
} finally {
1206+
setSavingQrUrl(false);
1207+
}
1208+
}}
1209+
disabled={savingQrUrl}
1210+
>
1211+
{savingQrUrl ? 'Saving...' : 'Save'}
1212+
</Button>
1213+
</div>
1214+
<p className="text-xs text-muted-foreground">
1215+
Useful when accessing SpoolmanSync through a reverse proxy or custom domain.
1216+
QR codes will link to this URL instead of the browser address bar URL.
1217+
</p>
1218+
</div>
1219+
</CardContent>
1220+
</Card>
1221+
11661222
</div>
11671223

11681224
{/* Add Printer Dialog */}

app/src/components/nfc-writer.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type SortBy = 'id' | 'name' | 'material' | 'vendor';
3535
interface NFCWriterProps {
3636
spools: Spool[];
3737
directAccessPort?: number;
38+
qrBaseUrl?: string;
3839
}
3940

4041
interface FilterField {
@@ -121,7 +122,7 @@ function getSpoolFieldValue(spool: Spool, fieldKey: string): string | null {
121122
}
122123
}
123124

124-
export function NFCWriter({ spools, directAccessPort }: NFCWriterProps) {
125+
export function NFCWriter({ spools, directAccessPort, qrBaseUrl }: NFCWriterProps) {
125126
const [selectedSpool, setSelectedSpool] = useState<Spool | null>(null);
126127
const [searchValue, setSearchValue] = useState('');
127128
const [filters, setFilters] = useState<Record<string, string | null>>({});
@@ -166,7 +167,7 @@ export function NFCWriter({ spools, directAccessPort }: NFCWriterProps) {
166167
}, [spools, filters, sortBy]);
167168

168169
const nfcUrl = selectedSpool
169-
? buildExternalUrl(`/scan/spool/${selectedSpool.id}`, directAccessPort)
170+
? buildExternalUrl(`/scan/spool/${selectedSpool.id}`, directAccessPort, qrBaseUrl)
170171
: null;
171172

172173
const handleSpoolSelect = (spool: Spool) => {
@@ -249,7 +250,7 @@ export function NFCWriter({ spools, directAccessPort }: NFCWriterProps) {
249250

250251
// Show unsupported message for non-NFC browsers
251252
if (!nfcSupported) {
252-
const baseUrl = typeof window !== 'undefined' ? buildExternalUrl('/scan/spool/', directAccessPort) : '/scan/spool/';
253+
const baseUrl = typeof window !== 'undefined' ? buildExternalUrl('/scan/spool/', directAccessPort, qrBaseUrl) : '/scan/spool/';
253254

254255
return (
255256
<Alert>

app/src/components/qr-code-generator.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ function sortSpools(spools: Spool[], sortBy: SortBy): Spool[] {
104104
interface QRCodeGeneratorProps {
105105
spools: Spool[];
106106
directAccessPort?: number;
107+
qrBaseUrl?: string;
107108
}
108109

109110
interface FilterField {
@@ -132,7 +133,7 @@ function getSpoolFieldValue(spool: Spool, fieldKey: string): string | null {
132133
}
133134
}
134135

135-
export function QRCodeGenerator({ spools, directAccessPort }: QRCodeGeneratorProps) {
136+
export function QRCodeGenerator({ spools, directAccessPort, qrBaseUrl }: QRCodeGeneratorProps) {
136137
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
137138
const [searchValue, setSearchValue] = useState('');
138139
const [filters, setFilters] = useState<Record<string, string | null>>({});
@@ -202,8 +203,8 @@ export function QRCodeGenerator({ spools, directAccessPort }: QRCodeGeneratorPro
202203
}, [spools, selectedIds]);
203204

204205
const labelItems = useMemo(() => {
205-
return buildLabelItems(selectedSpools, config, directAccessPort);
206-
}, [selectedSpools, config, directAccessPort]);
206+
return buildLabelItems(selectedSpools, config, directAccessPort, qrBaseUrl);
207+
}, [selectedSpools, config, directAccessPort, qrBaseUrl]);
207208

208209
const pages = useMemo(() => {
209210
return paginateItems(labelItems, config);

app/src/lib/ingress-path.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,21 @@ export function isIngressMode(): boolean {
4949
*
5050
* @param path - The path to build the URL for
5151
* @param directAccessPort - The direct access port (addon mode only, default 3000)
52+
* @param qrBaseUrl - Optional user-configured base URL override for QR codes/NFC tags
5253
*/
53-
export function buildExternalUrl(path: string, directAccessPort: number = 3000): string {
54+
export function buildExternalUrl(path: string, directAccessPort: number = 3000, qrBaseUrl?: string): string {
5455
if (typeof window === 'undefined') {
5556
return path;
5657
}
5758

5859
// Ensure path starts with /
5960
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
6061

62+
// User-configured override takes priority (e.g., for reverse proxy setups)
63+
if (qrBaseUrl) {
64+
return `${qrBaseUrl}${normalizedPath}`;
65+
}
66+
6167
if (isIngressMode()) {
6268
// In addon mode, use the Next.js server directly on the configured port
6369
// host_network: true makes this accessible from the local network

app/src/lib/label-sheet-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,12 @@ export function buildLabelItems(
140140
selectedSpools: Spool[],
141141
config: LabelSheetConfig,
142142
directAccessPort?: number,
143+
qrBaseUrl?: string,
143144
): LabelItem[] {
144145
const items: LabelItem[] = [];
145146

146147
for (const spool of selectedSpools) {
147-
const url = buildExternalUrl(`/scan/spool/${spool.id}`, directAccessPort);
148+
const url = buildExternalUrl(`/scan/spool/${spool.id}`, directAccessPort, qrBaseUrl);
148149
for (let i = 0; i < config.sheet.itemCopies; i++) {
149150
items.push({ spool, url });
150151
}

0 commit comments

Comments
 (0)