QR: Plik zip z kodami QR
This commit is contained in:
+210
-28
@@ -2,22 +2,30 @@
|
||||
require_once __DIR__ . '/auth.php';
|
||||
requireAdminAuth(true);
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
require_once __DIR__ . '/generator_helpers.php';
|
||||
|
||||
$tsql = "SELECT ID, Nazwa FROM dbo.NGastroStolik ORDER BY Nazwa";
|
||||
$stmt = sqlsrv_query($conn, $tsql);
|
||||
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/public/staff'));
|
||||
$publicDir = str_replace('\\', '/', dirname($scriptDir));
|
||||
$basePath = rtrim($publicDir, '/') . '/app.html?h=';
|
||||
$baseUrl = "{$scheme}://{$host}{$basePath}";
|
||||
[$baseUrl] = buildGuestAppBaseUrl();
|
||||
$previewQuery = 'preview=staff';
|
||||
|
||||
function appendPreviewParam(string $link, string $previewQuery): string
|
||||
{
|
||||
return str_contains($link, '?') ? $link . '&' . $previewQuery : $link . '?' . $previewQuery;
|
||||
$tables = [];
|
||||
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
|
||||
$id = strtoupper((string) $row['ID']);
|
||||
$nazwa = trim((string) $row['Nazwa']);
|
||||
$link = $baseUrl . $id;
|
||||
|
||||
$tables[] = [
|
||||
'id' => $id,
|
||||
'nazwa' => $nazwa,
|
||||
'link' => $link,
|
||||
'previewLink' => appendPreviewParam($link, $previewQuery),
|
||||
];
|
||||
}
|
||||
|
||||
$tables = buildUniqueQrFileNames($tables);
|
||||
$tablesJson = json_encode($tables, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
@@ -29,6 +37,11 @@ function appendPreviewParam(string $link, string $previewQuery): string
|
||||
body { font-family: Inter, Arial, sans-serif; padding: 20px; background: #0f172a; color: #e2e8f0; }
|
||||
h1 { margin: 0 0 10px; }
|
||||
p { color: #94a3b8; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 16px 0 20px; }
|
||||
.btn-zip { padding: 10px 16px; background: #22c55e; color: #0f172a; border: none; border-radius: 8px; cursor: pointer; font-weight: 700; }
|
||||
.btn-zip:hover:not(:disabled) { background: #16a34a; color: #fff; }
|
||||
.btn-zip:disabled { opacity: 0.65; cursor: wait; }
|
||||
.zip-status { font-size: 0.9rem; color: #cbd5e1; min-height: 1.2em; }
|
||||
table { border-collapse: collapse; width: 100%; max-width: 1100px; background: #111827; border: 1px solid #334155; }
|
||||
th, td { border: 1px solid #334155; padding: 10px; text-align: left; }
|
||||
th { background: #0b1220; }
|
||||
@@ -40,42 +53,61 @@ function appendPreviewParam(string $link, string $previewQuery): string
|
||||
.btn-preview:hover { background: #1e293b; border-color: #64748b; color: #e2e8f0; text-decoration: none; }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.btn-nav { display:inline-block; margin-right:8px; margin-bottom:14px; color:#cbd5e1; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px; }
|
||||
.file-name { font-size: 0.82rem; color: #94a3b8; margin-top: 4px; }
|
||||
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); align-items: center; justify-content: center; }
|
||||
.modal-content { background: #111827; border:1px solid #334155; padding: 20px; border-radius: 8px; text-align: center; max-width: 420px; width: 90%; }
|
||||
.close { float: right; font-size: 24px; font-weight: bold; cursor: pointer; line-height: 20px; color:#cbd5e1; }
|
||||
#qrcode { margin-top: 20px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="btn-nav" href="index.php">Wróć do panelu admina</a>
|
||||
<a class="btn-nav" href="logout.php">Wyloguj</a>
|
||||
<h1>Linki do aplikacji (kody QR)</h1>
|
||||
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach. Podgląd admina nie trafia do analityki.</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn-zip" id="downloadZipBtn" <?= $tables ? '' : 'disabled' ?>>
|
||||
Pobierz ZIP wszystkich kodów QR
|
||||
</button>
|
||||
<span class="zip-status" id="zipStatus"></span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Nazwa stolika</th>
|
||||
<th>Plik w ZIP</th>
|
||||
<th>Hash (ID z bazy)</th>
|
||||
<th>Bezpieczny link</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
<?php while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)): ?>
|
||||
<?php if (!$tables): ?>
|
||||
<tr>
|
||||
<td colspan="5">Brak stolików w bazie.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($tables as $table): ?>
|
||||
<?php
|
||||
$id = strtoupper((string)$row['ID']);
|
||||
$nazwa = htmlspecialchars((string)$row['Nazwa'], ENT_QUOTES, 'UTF-8');
|
||||
$link = $baseUrl . $id;
|
||||
$previewLink = appendPreviewParam($link, $previewQuery);
|
||||
$id = htmlspecialchars($table['id'], ENT_QUOTES, 'UTF-8');
|
||||
$nazwa = htmlspecialchars($table['nazwa'], ENT_QUOTES, 'UTF-8');
|
||||
$fileName = htmlspecialchars($table['fileName'], ENT_QUOTES, 'UTF-8');
|
||||
$displayName = htmlspecialchars(formatQrDisplayName($table['nazwa']), ENT_QUOTES, 'UTF-8');
|
||||
$link = htmlspecialchars($table['link'], ENT_QUOTES, 'UTF-8');
|
||||
$previewLink = htmlspecialchars($table['previewLink'], ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<tr>
|
||||
<td><strong><?= $nazwa ?></strong></td>
|
||||
<td><code><?= $fileName ?></code></td>
|
||||
<td style="font-size:.85em;color:#94a3b8;"><?= $id ?></td>
|
||||
<td><a href="<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>" target="_blank"><?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?></a></td>
|
||||
<td><a href="<?= $link ?>" target="_blank" rel="noopener"><?= $link ?></a></td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-qr" onclick="openQR('<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>', '<?= $nazwa ?>')">Pokaż QR</button>
|
||||
<a class="btn-preview" href="<?= htmlspecialchars($previewLink, ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener">Podgląd admina</a>
|
||||
<button type="button" class="btn-qr" data-link="<?= $link ?>" data-label="<?= $displayName ?>">Pokaż QR</button>
|
||||
<a class="btn-preview" href="<?= $previewLink ?>" target="_blank" rel="noopener">Podgląd admina</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endwhile; ?>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
|
||||
<div id="qrModal" class="modal">
|
||||
@@ -83,33 +115,183 @@ function appendPreviewParam(string $link, string $previewQuery): string
|
||||
<span class="close" onclick="closeQR()">×</span>
|
||||
<h2 id="qrTitle">Kod QR</h2>
|
||||
<div id="qrcode"></div>
|
||||
<p style="margin-top:20px;"><a id="qrLink" href="" target="_blank">Otwórz link w nowym oknie</a></p>
|
||||
<p style="margin-top:20px;"><a id="qrLink" href="" target="_blank" rel="noopener">Otwórz link w nowym oknie</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TABLES = <?= $tablesJson ?: '[]' ?>;
|
||||
const QR_LIGHT = "rgba(255,255,255,0)";
|
||||
|
||||
function buildQrOptions(text, size) {
|
||||
return {
|
||||
text,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: "#000000",
|
||||
colorLight: QR_LIGHT,
|
||||
correctLevel: QRCode.CorrectLevel.H
|
||||
};
|
||||
}
|
||||
|
||||
function stripWhiteBackground(canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
if (r > 245 && g > 245 && b > 245) {
|
||||
data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function canvasToTransparentPngBlob(canvas) {
|
||||
stripWhiteBackground(canvas);
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
reject(new Error("Nie udało się wygenerować obrazu QR."));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function openQR(link, nazwa) {
|
||||
document.getElementById("qrModal").style.display = "flex";
|
||||
document.getElementById("qrTitle").innerText = "Stolik: " + nazwa;
|
||||
document.getElementById("qrTitle").innerText = nazwa;
|
||||
document.getElementById("qrLink").href = link;
|
||||
|
||||
const qrContainer = document.getElementById("qrcode");
|
||||
qrContainer.innerHTML = "";
|
||||
|
||||
new QRCode(qrContainer, {
|
||||
text: link,
|
||||
width: 250,
|
||||
height: 250,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.H
|
||||
});
|
||||
new QRCode(qrContainer, buildQrOptions(link, 250));
|
||||
}
|
||||
|
||||
function closeQR() {
|
||||
document.getElementById("qrModal").style.display = "none";
|
||||
}
|
||||
|
||||
function createQrBlob(link, size = 512) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const holder = document.createElement("div");
|
||||
holder.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none;";
|
||||
document.body.appendChild(holder);
|
||||
|
||||
try {
|
||||
new QRCode(holder, buildQrOptions(link, size));
|
||||
} catch (error) {
|
||||
holder.remove();
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const canvas = holder.querySelector("canvas");
|
||||
const img = holder.querySelector("img");
|
||||
|
||||
if (canvas) {
|
||||
canvasToTransparentPngBlob(canvas)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => holder.remove());
|
||||
return;
|
||||
}
|
||||
|
||||
if (img) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = size;
|
||||
tempCanvas.height = size;
|
||||
const ctx = tempCanvas.getContext("2d");
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
canvasToTransparentPngBlob(tempCanvas)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => holder.remove());
|
||||
};
|
||||
|
||||
if (img.complete) {
|
||||
draw();
|
||||
} else {
|
||||
img.onload = draw;
|
||||
img.onerror = () => {
|
||||
holder.remove();
|
||||
reject(new Error("Nie udało się wczytać obrazu QR."));
|
||||
};
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
holder.remove();
|
||||
reject(new Error("Brak renderu QR."));
|
||||
}, 80);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadAllQrZip() {
|
||||
if (!TABLES.length || typeof JSZip === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.getElementById("downloadZipBtn");
|
||||
const status = document.getElementById("zipStatus");
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
const total = TABLES.length;
|
||||
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
const table = TABLES[i];
|
||||
status.textContent = `Generowanie ${i + 1}/${total}: ${table.fileName}`;
|
||||
const blob = await createQrBlob(table.link, 512);
|
||||
zip.file(table.fileName, blob);
|
||||
}
|
||||
|
||||
status.textContent = "Pakowanie ZIP…";
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `kody-qr-stoliki-${stamp}.zip`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.textContent = `Gotowe — pobrano ${total} plików QR.`;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
status.textContent = "Błąd generowania ZIP. Spróbuj ponownie.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".btn-qr").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
openQR(button.dataset.link, button.dataset.label);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("downloadZipBtn")?.addEventListener("click", downloadAllQrZip);
|
||||
|
||||
window.onclick = function (event) {
|
||||
const modal = document.getElementById("qrModal");
|
||||
if (event.target === modal) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function formatQrDisplayName(string $nazwa): string
|
||||
{
|
||||
$nazwa = trim($nazwa);
|
||||
if ($nazwa === '') {
|
||||
return 'Stolik';
|
||||
}
|
||||
|
||||
if (preg_match('/^stolik\s*(.*)$/iu', $nazwa, $matches)) {
|
||||
$suffix = trim((string) $matches[1]);
|
||||
return $suffix !== '' ? 'Stolik ' . $suffix : 'Stolik';
|
||||
}
|
||||
|
||||
return 'Stolik ' . $nazwa;
|
||||
}
|
||||
|
||||
function sanitizeZipFileName(string $name): string
|
||||
{
|
||||
$name = preg_replace('/[\\\\\\/:*?"<>|]+/u', '-', $name) ?? $name;
|
||||
$name = preg_replace('/\\s+/u', ' ', $name) ?? $name;
|
||||
$name = trim($name, " .-\t");
|
||||
|
||||
return $name !== '' ? $name : 'Stolik';
|
||||
}
|
||||
|
||||
function buildUniqueQrFileNames(array $tables): array
|
||||
{
|
||||
$used = [];
|
||||
|
||||
foreach ($tables as &$table) {
|
||||
$base = sanitizeZipFileName(formatQrDisplayName((string) ($table['nazwa'] ?? '')));
|
||||
$candidate = $base;
|
||||
$index = 2;
|
||||
|
||||
while (isset($used[$candidate])) {
|
||||
$candidate = $base . ' (' . $index . ')';
|
||||
$index++;
|
||||
}
|
||||
|
||||
$used[$candidate] = true;
|
||||
$table['fileName'] = $candidate . '.png';
|
||||
}
|
||||
unset($table);
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
function buildGuestAppBaseUrl(): array
|
||||
{
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/public/staff'));
|
||||
$publicDir = str_replace('\\', '/', dirname($scriptDir));
|
||||
$basePath = rtrim($publicDir, '/') . '/app.html?h=';
|
||||
$baseUrl = "{$scheme}://{$host}{$basePath}";
|
||||
|
||||
return [$baseUrl, $basePath];
|
||||
}
|
||||
|
||||
function appendPreviewParam(string $link, string $previewQuery): string
|
||||
{
|
||||
return str_contains($link, '?') ? $link . '&' . $previewQuery : $link . '?' . $previewQuery;
|
||||
}
|
||||
Reference in New Issue
Block a user