Fase 3 priority 2: presets, random style, qr scanner, PWA

Preset system: qr_presets table (migration 005) plus a presets.php AJAX
endpoint (list/save/delete, CSRF-protected, scoped to the logged-in user's
own id - presets are personal, never shared across accounts). UI/JS lives in
dist/js/qrcode-style-tools.js.

Random style button: client-side only, fills foreground/background with a
random hex color pair (playful randomize, no contrast/scannability
guarantee).

Qr scanner (scan_qrcode.php): camera or image upload, decoded entirely
client-side via html5-qrcode (CDN, pinned to 2.3.8).

PWA: manifest.json + service-worker.js, icons generated from the existing
DynamicQRCode_Original.png glyph. The service worker only caches static
assets (css/js/images) and deliberately never touches PHP pages, since those
carry CSRF tokens and session-specific content that must never be cached.

Fixes a gap found while testing: qrcode_options.php is only a shared partial
for the static qr forms - the dynamic qr form (form_dynamic_add.php) has its
own separate copy of the foreground/background/level/size/filename/format
fields (pre-existing structure, not something introduced here). That meant
frame_text and the new preset/random-style UI never showed up on the
dynamic qr page. Added the same fields there too, verified with a dynamic qr
plus frame text (150x180px, the expected +30px padding).
This commit is contained in:
2026-07-09 00:49:45 +02:00
parent 4e9fed755c
commit c3c6f167e0
14 changed files with 580 additions and 6 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+169
View File
@@ -0,0 +1,169 @@
// Preset system + random style button for the qr code generation forms (Fase 3, priority 2).
(function () {
function csrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function setColor(id, hex) {
var input = document.getElementById(id);
if (!input) {
return;
}
input.value = hex;
input.dispatchEvent(new Event('change'));
try {
// Sync the bootstrap-colorpicker widget/swatch if it was initialized on this input.
if (window.jQuery) {
jQuery(input).colorpicker('setValue', hex);
}
} catch (e) {
// Colorpicker not initialized on this page - the raw value above is still correct.
}
}
function randomHexColor() {
var value = Math.floor(Math.random() * 0xFFFFFF).toString(16);
return '#' + ('000000' + value).slice(-6);
}
function loadPresets() {
var select = document.getElementById('preset_select');
if (!select) {
return;
}
fetch('presets.php?action=list')
.then(function (response) { return response.json(); })
.then(function (json) {
if (json.status !== 200) {
return;
}
json.data.forEach(function (preset) {
var option = document.createElement('option');
option.value = preset.id;
option.textContent = preset.name;
option.dataset.foreground = preset.foreground;
option.dataset.background = preset.background;
option.dataset.level = preset.level;
option.dataset.size = preset.size;
select.appendChild(option);
});
})
.catch(function () { /* presets are a nice-to-have, fail silently */ });
}
document.addEventListener('DOMContentLoaded', function () {
loadPresets();
var randomBtn = document.getElementById('random_style_btn');
if (randomBtn) {
randomBtn.addEventListener('click', function () {
setColor('foreground', randomHexColor());
setColor('background', randomHexColor());
});
}
var presetSelect = document.getElementById('preset_select');
if (presetSelect) {
presetSelect.addEventListener('change', function () {
var option = presetSelect.options[presetSelect.selectedIndex];
if (!option.value) {
return;
}
setColor('foreground', option.dataset.foreground);
setColor('background', option.dataset.background);
var levelSelect = document.querySelector('select[name="level"]');
if (levelSelect) {
levelSelect.value = option.dataset.level;
}
var sizeSelect = document.getElementById('size');
if (sizeSelect) {
sizeSelect.value = option.dataset.size;
}
});
}
var saveBtn = document.getElementById('preset_save_btn');
if (saveBtn) {
saveBtn.addEventListener('click', function () {
var nameInput = document.getElementById('preset_name');
var name = nameInput.value.trim();
if (!name) {
alert('Enter a name for this preset first.');
return;
}
var body = new URLSearchParams();
body.set('action', 'save');
body.set('name', name);
body.set('foreground', document.getElementById('foreground').value);
body.set('background', document.getElementById('background').value);
body.set('level', document.querySelector('select[name="level"]').value);
body.set('size', document.getElementById('size').value);
fetch('presets.php', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken() },
body: body
})
.then(function (response) { return response.json(); })
.then(function (json) {
if (json.status !== 200) {
alert('Could not save preset: ' + json.data);
return;
}
var option = document.createElement('option');
option.value = json.data.id;
option.textContent = json.data.name;
option.dataset.foreground = document.getElementById('foreground').value;
option.dataset.background = document.getElementById('background').value;
option.dataset.level = document.querySelector('select[name="level"]').value;
option.dataset.size = document.getElementById('size').value;
presetSelect.appendChild(option);
presetSelect.value = option.value;
nameInput.value = '';
})
.catch(function () { alert('Could not save preset (network error).'); });
});
}
var deleteBtn = document.getElementById('preset_delete_btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', function () {
var option = presetSelect.options[presetSelect.selectedIndex];
if (!option.value) {
return;
}
if (!confirm('Delete preset "' + option.textContent + '"?')) {
return;
}
var body = new URLSearchParams();
body.set('action', 'delete');
body.set('id', option.value);
fetch('presets.php', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken() },
body: body
})
.then(function (response) { return response.json(); })
.then(function (json) {
if (json.status === 200) {
option.remove();
} else {
alert('Could not delete preset: ' + json.data);
}
})
.catch(function () { alert('Could not delete preset (network error).'); });
});
}
});
})();
+44 -2
View File
@@ -35,7 +35,7 @@
<div class="col-6 col-md-3">
<label for="size">Size (px)</label>
<select name="size" class="form-control">
<select name="size" id="size" class="form-control">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
@@ -51,13 +51,47 @@
</div>
</div>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
<button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block">
<i class="fa fa-dice"></i> Random style
</button>
</div>
<div class="col-6 col-md-3">
<label for="preset_select">Load preset</label>
<div class="input-group">
<select id="preset_select" class="form-control">
<option value="">-- Select --</option>
</select>
<div class="input-group-append">
<button type="button" id="preset_delete_btn" class="btn btn-outline-secondary" title="Delete selected preset"><i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<label for="preset_name">Save as preset</label>
<div class="input-group">
<input type="text" id="preset_name" class="form-control" placeholder="Preset name" maxlength="50">
<div class="input-group-append">
<button type="button" id="preset_save_btn" class="btn btn-outline-secondary"><i class="fa fa-save"></i></button>
</div>
</div>
</div>
</div>
</div>
<script src="dist/js/qrcode-style-tools.js?nocache=<?php print rand();?>"></script>
<!-- Its use is not recommended. Read the documentation
<div class="form-group">
<label for="logo">Logo</label>
<?php //include 'logo.php' ?>
</div>
-->
<div class="col-sm-4">
<div class="form-group">
<label for="link">URL *</label>
@@ -94,6 +128,14 @@
<option value="eps">EPS</option>
</select>
</div>
<div class="col-sm-4">
<div class="form-group">
<label for="frame_text">Frame text</label>
<input type="text" name="frame_text" value="" placeholder="e.g. Scan me" maxlength="60" class="form-control" id="frame_text">
<small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small>
</div>
</div>
</div>
</div>
+37 -3
View File
@@ -34,7 +34,7 @@
<div class="col-6 col-md-3">
<label for="size">Size (px)</label>
<select name="size" class="form-control">
<select name="size" id="size" class="form-control">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
@@ -54,14 +54,48 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
</div>
</div>
</div>
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-6 col-md-3">
<button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block">
<i class="fa fa-dice"></i> Random style
</button>
</div>
<div class="col-6 col-md-3">
<label for="preset_select">Load preset</label>
<div class="input-group">
<select id="preset_select" class="form-control">
<option value="">-- Select --</option>
</select>
<div class="input-group-append">
<button type="button" id="preset_delete_btn" class="btn btn-outline-secondary" title="Delete selected preset"><i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<label for="preset_name">Save as preset</label>
<div class="input-group">
<input type="text" id="preset_name" class="form-control" placeholder="Preset name" maxlength="50">
<div class="input-group-append">
<button type="button" id="preset_save_btn" class="btn btn-outline-secondary"><i class="fa fa-save"></i></button>
</div>
</div>
</div>
</div>
</div>
<script src="dist/js/qrcode-style-tools.js?nocache=<?php print rand();?>"></script>
<!-- Its use is not recommended. Read the documentation
<div class="form-group">
<label for="logo">Logo</label>
<?php //include 'logo.php' ?>
</div>
-->
<div class="col-sm-12 mb-2">
<div class="row">
<div class="col-sm-4">
+7 -1
View File
@@ -28,4 +28,10 @@
<!-- date-range-picker -->
<script src="plugins/daterangepicker/daterangepicker.js"></script>
<!-- Overlay scrollbar -->
<script type="text/javascript" src="plugins/overlayScrollbars/js/OverlayScrollbars.js"></script>
<script type="text/javascript" src="plugins/overlayScrollbars/js/OverlayScrollbars.js"></script>
<!-- PWA service worker -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js');
}
</script>
+3
View File
@@ -2,6 +2,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="csrf-token" content="<?php echo csrf_token(); ?>">
<meta name="theme-color" content="#007bff">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="dist/img/icon-192.png">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="plugins/fontawesome-free/css/all.min.css">
+8
View File
@@ -32,6 +32,14 @@
Dashboard
</p>
</a>
</li>
<li class="nav-item">
<a href="./scan_qrcode.php" <?php echo (CURRENT_PAGE == 'scan_qrcode.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
<i class="nav-icon fas fa-camera"></i>
<p>
Scan qr code
</p>
</a>
</li>
<?php if ($_SESSION['type'] !== 'user' || !empty($_SESSION['can_view_dynamic'])): ?>
<li <?php echo ((substr(CURRENT_PAGE, 0, 19) == 'dynamic_qrcodes.php') || (substr(CURRENT_PAGE, 0, 18) == 'dynamic_qrcode.php')) ? ' class="nav-item has-treeview menu-open"' : ' class="nav-item has-treeview"'; ?>>
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Qrcode Generator",
"short_name": "QRcode",
"description": "Self-hosted static and dynamic qr code generator",
"start_url": "index.php",
"scope": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#007bff",
"icons": [
{ "src": "dist/img/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "dist/img/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* AJAX endpoint for saved color/style presets (Fase 3, priority 2). Presets are
* personal: scoped to the logged-in user's own id, never shared across accounts.
*/
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
header('Content-Type: application/json');
$action = $_GET['action'] ?? $_POST['action'] ?? '';
if ($action === 'list') {
$db = getDbInstance();
$db->where('user_id', $_SESSION['user_id']);
$db->orderBy('name', 'ASC');
$presets = $db->get('qr_presets', null, ['id', 'name', 'foreground', 'background', 'level', 'size']);
echo json_encode(['status' => 200, 'data' => $presets]);
exit;
}
if ($action === 'save') {
csrf_verify_header_or_die();
$name = trim((string) ($_POST['name'] ?? ''));
$foreground = trim((string) ($_POST['foreground'] ?? ''));
$background = trim((string) ($_POST['background'] ?? ''));
$level = $_POST['level'] ?? 'L';
$size = filter_var($_POST['size'] ?? 200, FILTER_VALIDATE_INT);
if ($name === '' || strlen($name) > 50) {
echo json_encode(['status' => 400, 'data' => 'Preset name must be between 1 and 50 characters.']);
exit;
}
if (!preg_match('/^#?[0-9a-fA-F]{6}$/', $foreground) || !preg_match('/^#?[0-9a-fA-F]{6}$/', $background)) {
echo json_encode(['status' => 400, 'data' => 'Foreground/background must be valid hex colors.']);
exit;
}
if (!in_array($level, ['L', 'M', 'Q', 'H'], true)) {
$level = 'L';
}
if ($size === false) {
$size = 200;
}
$db = getDbInstance();
$last_id = $db->insert('qr_presets', [
'user_id' => $_SESSION['user_id'],
'name' => $name,
'foreground' => $foreground,
'background' => $background,
'level' => $level,
'size' => $size,
'created_at' => date('Y-m-d H:i:s'),
]);
if (!$last_id) {
echo json_encode(['status' => 500, 'data' => 'Could not save preset.']);
exit;
}
echo json_encode(['status' => 200, 'data' => ['id' => $last_id, 'name' => $name]]);
exit;
}
if ($action === 'delete') {
csrf_verify_header_or_die();
$id = filter_var($_POST['id'] ?? null, FILTER_VALIDATE_INT);
if (!$id) {
echo json_encode(['status' => 400, 'data' => 'Invalid preset id.']);
exit;
}
$db = getDbInstance();
$db->where('id', $id);
$db->where('user_id', $_SESSION['user_id']);
$deleted = $db->delete('qr_presets');
echo json_encode(['status' => $deleted ? 200 : 404, 'data' => $deleted ? 'Deleted' : 'Preset not found']);
exit;
}
echo json_encode(['status' => 400, 'data' => 'Unknown action']);
+148
View File
@@ -0,0 +1,148 @@
<?php
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
?>
<!DOCTYPE html>
<html lang="en">
<title>Qrcode Generator</title>
<head>
<?php include './includes/head.php'; ?>
</head>
<body class="hold-transition sidebar-mini layout-fixed layout-navbar-fixed layout-footer-fixed">
<div class="wrapper">
<!-- Navbar -->
<?php include './includes/navbar.php'; ?>
<!-- /.navbar -->
<!-- Main Sidebar Container -->
<?php include './includes/sidebar.php'; ?>
<!-- /.Main Sidebar Container -->
<!-- Content Wrapper. Contains page content -->
<div class="content-wrapper">
<!-- Content Header (Page header) -->
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Scan a qr code</h1>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- /.container-fluid -->
</div>
<!-- /.content-header -->
<!-- Main content -->
<section class="content">
<div class="container-fluid">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Scan from camera or upload an image</h3>
</div>
<div class="card-body">
<p class="text-muted">Decoding happens entirely in your browser - no image is uploaded to the server.</p>
<button type="button" id="start_camera_btn" class="btn btn-primary mb-3">
<i class="fa fa-camera"></i> Start camera
</button>
<button type="button" id="stop_camera_btn" class="btn btn-secondary mb-3" style="display:none;">
<i class="fa fa-stop"></i> Stop camera
</button>
<div id="camera_reader" style="max-width: 500px;"></div>
<div class="form-group mt-3">
<label for="qr_image_input">...or upload an image</label>
<input type="file" id="qr_image_input" accept="image/*" class="form-control">
</div>
<div id="scan_result_wrapper" class="mt-3" style="display:none;">
<label>Decoded content</label>
<div class="input-group">
<input type="text" id="scan_result" class="form-control" readonly>
<div class="input-group-append">
<button type="button" id="copy_result_btn" class="btn btn-outline-secondary"><i class="fa fa-copy"></i></button>
</div>
</div>
</div>
</div>
</div>
</div><!--/. container-fluid -->
</section><!-- /.content -->
</div><!-- /.content-wrapper -->
<!-- Footer and scripts -->
<?php include './includes/footer.php'; ?>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
(function () {
var resultInput = document.getElementById('scan_result');
var resultWrapper = document.getElementById('scan_result_wrapper');
var html5QrCode = new Html5Qrcode('camera_reader');
var cameraRunning = false;
function showResult(text) {
resultInput.value = text;
resultWrapper.style.display = '';
}
document.getElementById('start_camera_btn').addEventListener('click', function () {
var startBtn = this;
var stopBtn = document.getElementById('stop_camera_btn');
html5QrCode.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: 250 },
function (decodedText) {
showResult(decodedText);
}
).then(function () {
cameraRunning = true;
startBtn.style.display = 'none';
stopBtn.style.display = '';
}).catch(function (err) {
alert('Could not start the camera: ' + err);
});
});
document.getElementById('stop_camera_btn').addEventListener('click', function () {
var startBtn = document.getElementById('start_camera_btn');
var stopBtn = this;
if (!cameraRunning) {
return;
}
html5QrCode.stop().then(function () {
cameraRunning = false;
startBtn.style.display = '';
stopBtn.style.display = 'none';
});
});
document.getElementById('qr_image_input').addEventListener('change', function (event) {
var file = event.target.files[0];
if (!file) {
return;
}
html5QrCode.scanFile(file, false)
.then(function (decodedText) {
showResult(decodedText);
})
.catch(function (err) {
alert('Could not find a qr code in this image: ' + err);
});
});
document.getElementById('copy_result_btn').addEventListener('click', function () {
navigator.clipboard.writeText(resultInput.value).catch(function () {
resultInput.select();
document.execCommand('copy');
});
});
})();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
// Minimal service worker: only makes the app installable and caches truly static
// assets. Deliberately never caches PHP pages or the presets/bulk_action/qrcode_image
// endpoints - those carry session-specific and CSRF-sensitive content.
const CACHE_NAME = 'qrcode-static-v1';
const STATIC_ASSET_PATTERN = /\.(css|js|png|jpg|jpeg|svg|gif|woff2?|ttf)$/;
self.addEventListener('install', function (event) {
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function (event) {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || url.origin !== self.location.origin || !STATIC_ASSET_PATTERN.test(url.pathname)) {
return;
}
event.respondWith(
caches.open(CACHE_NAME).then(function (cache) {
return cache.match(event.request).then(function (cached) {
const fetchPromise = fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});