Add index.php
This commit is contained in:
commit
bc03c6b514
1 changed files with 486 additions and 0 deletions
486
index.php
Normal file
486
index.php
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
<?php
|
||||
/**
|
||||
* LAN Print Portal — single file, no dependencies, no database, no CDN.
|
||||
*
|
||||
* Needs: nginx + php-fpm + CUPS (cups, cups-client) with a printer already set up.
|
||||
* Works on PHP 7.4 through 8.x.
|
||||
* Uploaded files are saved in STORE_DIR so they can be searched and printed again.
|
||||
* There is no login: restrict access with nginx (allow/deny) — see the setup notes.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
// ---------- Settings (the only part you should ever need to edit) ----------
|
||||
const SITE_TITLE = 'Print';
|
||||
const PRINTER = ''; // '' = let people choose / use the CUPS default. Or e.g. 'Brother_HL'
|
||||
const CUPS_SERVER = ''; // '' = this machine. Or 'host:631' to print via another CUPS server
|
||||
const MAX_COPIES = 20;
|
||||
const MAX_FILES = 10;
|
||||
const MAX_MB = 50; // keep in sync with the nginx / PHP limits
|
||||
const SHOW_QUEUE = true; // show waiting jobs, with a cancel button
|
||||
const LP = '/usr/bin/lp';
|
||||
const LPSTAT = '/usr/bin/lpstat';
|
||||
const CANCEL = '/usr/bin/cancel';
|
||||
|
||||
const STORE_DIR = '/home/clay/Programs/tg_print/printme'; // must be writable by the web user
|
||||
const RECENT_COUNT = 8; // files shown when not searching
|
||||
const TIMEZONE = ''; // '' = use the system timezone (/etc/timezone)
|
||||
|
||||
// Extension => MIME types accepted (CUPS handles these without extra packages)
|
||||
const ALLOWED = [
|
||||
'pdf' => ['application/pdf'],
|
||||
'txt' => ['text/plain'],
|
||||
'jpg' => ['image/jpeg'],
|
||||
'jpeg' => ['image/jpeg'],
|
||||
'png' => ['image/png'],
|
||||
'gif' => ['image/gif'],
|
||||
];
|
||||
|
||||
// ---------- Optional add-ons (extensions.php). The portal works fine without it. ----------
|
||||
if (is_file(__DIR__ . '/extensions.php')) { require __DIR__ . '/extensions.php'; }
|
||||
if (!function_exists('ext_boot')) { function ext_boot(): void {} }
|
||||
if (!function_exists('ext_css')) { function ext_css(): string { return ''; } }
|
||||
if (!function_exists('ext_form_html')) { function ext_form_html(): string { return ''; } }
|
||||
if (!function_exists('ext_file_html')) { function ext_file_html(string $name): string { return ''; } }
|
||||
if (!function_exists('ext_lp_args')) { function ext_lp_args(string $ext): array { return []; } }
|
||||
if (!function_exists('ext_accept')) { function ext_accept(): string { return ''; } }
|
||||
if (!function_exists('ext_hint')) { function ext_hint(): string { return ''; } }
|
||||
if (!function_exists('ext_page_html')) { function ext_page_html(): string { return ''; } }
|
||||
if (!function_exists('ext_convert')) { function ext_convert(string $tmp, string $orig): ?array { return null; } }
|
||||
|
||||
// ---------- Helpers ----------
|
||||
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
|
||||
function run(array $cmd): array {
|
||||
$env = ['PATH' => '/usr/bin:/bin', 'LC_ALL' => 'C'];
|
||||
if (CUPS_SERVER !== '') { $env['CUPS_SERVER'] = CUPS_SERVER; }
|
||||
$p = @proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
|
||||
if (!is_resource($p)) { return [1, '', 'Could not start ' . basename($cmd[0])]; }
|
||||
$out = stream_get_contents($pipes[1]);
|
||||
$err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]); fclose($pipes[2]);
|
||||
return [proc_close($p), (string)$out, trim((string)$err)];
|
||||
}
|
||||
|
||||
function printers(): array {
|
||||
[, $out] = run([LPSTAT, '-p']);
|
||||
$list = [];
|
||||
if (preg_match_all('/^printer (\S+) (.*)$/m', $out, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $row) {
|
||||
$state = 'Ready';
|
||||
if (stripos($row[2], 'disabled') !== false) { $state = 'Offline'; }
|
||||
elseif (stripos($row[2], 'now printing') !== false) { $state = 'Printing'; }
|
||||
$list[$row[1]] = $state;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
function default_printer(): string {
|
||||
[, $out] = run([LPSTAT, '-d']);
|
||||
return preg_match('/destination: (\S+)/', $out, $m) ? $m[1] : '';
|
||||
}
|
||||
|
||||
function jobs(): array {
|
||||
[, $out] = run([LPSTAT, '-o']);
|
||||
$list = [];
|
||||
foreach (preg_split('/\R/', trim($out)) as $line) {
|
||||
if (preg_match('/^(\S+-\d+)\s+(\S+)\s+(\d+)/', $line, $m)) {
|
||||
$list[] = ['id' => $m[1], 'user' => $m[2], 'kb' => max(1, (int)round(((int)$m[3]) / 1024))];
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
function flash(string $type, string $msg): void { $_SESSION['flash'][] = [$type, $msg]; }
|
||||
|
||||
function cut(string $s, int $n): string {
|
||||
return preg_match('/^.{0,' . $n . '}/su', $s, $m) ? $m[0] : substr($s, 0, $n);
|
||||
}
|
||||
|
||||
function fmt_size(int $b): string {
|
||||
return $b >= 1048576 ? round($b / 1048576, 1) . ' MB' : max(1, (int)round($b / 1024)) . ' KB';
|
||||
}
|
||||
|
||||
function storage_ready(): bool {
|
||||
return (is_dir(STORE_DIR) || @mkdir(STORE_DIR, 0775, true)) && is_writable(STORE_DIR);
|
||||
}
|
||||
|
||||
function safe_name(string $orig, string $ext): string {
|
||||
$base = trim((string)preg_replace('/[^\w .()-]+/u', '_', pathinfo($orig, PATHINFO_FILENAME)), " ._-");
|
||||
return cut($base === '' ? 'file' : $base, 80) . '.' . $ext;
|
||||
}
|
||||
|
||||
function unique_path(string $name): string {
|
||||
$p = STORE_DIR . '/' . $name;
|
||||
if (!file_exists($p)) { return $p; }
|
||||
$base = pathinfo($name, PATHINFO_FILENAME);
|
||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||
for ($i = 2; $i < 1000; $i++) {
|
||||
$p = STORE_DIR . "/$base-$i.$ext";
|
||||
if (!file_exists($p)) { return $p; }
|
||||
}
|
||||
return STORE_DIR . "/$base-" . bin2hex(random_bytes(3)) . ".$ext";
|
||||
}
|
||||
|
||||
function stored_path(string $name): ?string {
|
||||
if ($name === '' || $name[0] === '.' || $name !== basename($name) || strpos($name, "\0") !== false) { return null; }
|
||||
$p = STORE_DIR . '/' . $name;
|
||||
return is_file($p) ? $p : null;
|
||||
}
|
||||
|
||||
function list_files(string $q, int $limit): array {
|
||||
$terms = $q === '' ? [] : preg_split('/\s+/', $q);
|
||||
$out = [];
|
||||
if (is_dir(STORE_DIR)) {
|
||||
foreach (new DirectoryIterator(STORE_DIR) as $f) {
|
||||
$name = $f->getFilename();
|
||||
if (!$f->isFile() || $name[0] === '.' || !isset(ALLOWED[strtolower($f->getExtension())])) { continue; }
|
||||
foreach ($terms as $t) {
|
||||
if (!preg_match('/' . preg_quote($t, '/') . '/iu', $name)) { continue 2; }
|
||||
}
|
||||
$out[] = ['name' => $name, 'time' => $f->getMTime(), 'size' => $f->getSize()];
|
||||
}
|
||||
}
|
||||
usort($out, function ($a, $b) { return $b['time'] <=> $a['time']; });
|
||||
return ['total' => count($out), 'items' => array_slice($out, 0, $limit)];
|
||||
}
|
||||
|
||||
function send_job(string $path, string $dest, int $copies, string $pages, bool $duplex, bool $mono): void {
|
||||
$name = basename($path);
|
||||
$label = '“' . cut($name, 60) . '”';
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
if (!isset(ALLOWED[$ext]) || !in_array((new finfo(FILEINFO_MIME_TYPE))->file($path), ALLOWED[$ext], true)) {
|
||||
flash('err', "$label isn’t a printable file."); return;
|
||||
}
|
||||
$cmd = [LP, '-d', $dest, '-n', (string)$copies, '-t', cut((string)preg_replace('/[^\w .-]/u', '_', $name), 80)];
|
||||
$cmd[] = '-o'; $cmd[] = $duplex ? 'sides=two-sided-long-edge' : 'sides=one-sided';
|
||||
if ($mono) { $cmd[] = '-o'; $cmd[] = 'print-color-mode=monochrome'; }
|
||||
if ($pages !== '') { $cmd[] = '-o'; $cmd[] = 'page-ranges=' . $pages; }
|
||||
if ($ext !== 'pdf' && $ext !== 'txt') {
|
||||
$actual = isset($_GET['admin']) && ($_POST['actual'] ?? '') === '1'; // admin only: natural size instead of fit
|
||||
$cmd[] = '-o'; $cmd[] = $actual ? 'print-scaling=none' : 'fit-to-page';
|
||||
}
|
||||
foreach (ext_lp_args($ext) as $arg) { $cmd[] = $arg; }
|
||||
$cmd[] = $path;
|
||||
[$code, , $err] = run($cmd);
|
||||
if ($code === 0) { flash('ok', "$label sent to $dest."); }
|
||||
else { flash('err', "$label failed: " . ($err ?: 'unknown printing error') . '.'); }
|
||||
}
|
||||
|
||||
function upload_error(int $code): string {
|
||||
switch ($code) {
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE: return 'is too large';
|
||||
case UPLOAD_ERR_PARTIAL: return 'was only partly received — try again';
|
||||
case UPLOAD_ERR_NO_TMP_DIR:
|
||||
case UPLOAD_ERR_CANT_WRITE: return 'could not be saved on the server';
|
||||
default: return 'could not be uploaded';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Session / CSRF ----------
|
||||
if (TIMEZONE !== '') { @date_default_timezone_set(TIMEZONE); }
|
||||
else {
|
||||
$tzf = trim((string)@file_get_contents('/etc/timezone'));
|
||||
if ($tzf !== '' && in_array($tzf, timezone_identifiers_list(), true)) { date_default_timezone_set($tzf); }
|
||||
}
|
||||
session_name('lanprint');
|
||||
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict']);
|
||||
session_start();
|
||||
if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(16)); }
|
||||
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
/*
|
||||
* Permit this print portal to load inside Organizr,
|
||||
* as long as both are accessed from the same scheme/host/port.
|
||||
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
|
||||
header(
|
||||
"Content-Security-Policy: "
|
||||
. "default-src 'none'; "
|
||||
. "style-src 'unsafe-inline'; "
|
||||
. "script-src 'unsafe-inline'; "
|
||||
. "form-action 'self'; "
|
||||
. "base-uri 'none'; "
|
||||
. "frame-ancestors 'self'"
|
||||
);
|
||||
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Cache-Control: no-store');
|
||||
*/
|
||||
ext_boot();
|
||||
|
||||
// ---------- Actions ----------
|
||||
$q = cut(trim((string)($_POST['q'] ?? $_GET['q'] ?? '')), 100);
|
||||
$admin = isset($_GET['admin']); // Delete buttons only appear at /print/?admin
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$printers = printers();
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
|
||||
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && !$_POST && !$_FILES) {
|
||||
flash('err', 'That upload was too large. The limit is ' . MAX_MB . ' MB in total.');
|
||||
} elseif (!hash_equals($_SESSION['csrf'], (string)($_POST['csrf'] ?? ''))) {
|
||||
flash('err', 'This page expired. Please try again.');
|
||||
} elseif ($action === 'cancel') {
|
||||
$job = (string)($_POST['job'] ?? '');
|
||||
if (preg_match('/^[A-Za-z0-9_.-]+-\d+$/', $job)) {
|
||||
[$code, , $err] = run([CANCEL, $job]);
|
||||
$code === 0 ? flash('ok', 'Job cancelled.') : flash('err', 'Could not cancel the job. ' . $err);
|
||||
}
|
||||
} elseif ($action === 'delete') {
|
||||
$path = $admin ? stored_path((string)($_POST['file'] ?? '')) : null;
|
||||
if ($path !== null && @unlink($path)) { flash('ok', 'Deleted “' . cut(basename($path), 60) . '”.'); }
|
||||
else { flash('err', 'Could not delete that file.'); }
|
||||
} else {
|
||||
$dest = PRINTER !== '' ? PRINTER : (string)($_POST['printer'] ?? default_printer());
|
||||
$copies = max(1, min(MAX_COPIES, (int)($_POST['copies'] ?? 1)));
|
||||
$pages = preg_replace('/\s+/', '', (string)($_POST['pages'] ?? ''));
|
||||
$duplex = ($_POST['duplex'] ?? '') === '1';
|
||||
$mono = ($_POST['mono'] ?? '') === '1';
|
||||
$reprint = (string)($_POST['reprint'] ?? '');
|
||||
$names = (array)($_FILES['files']['name'] ?? []);
|
||||
|
||||
if (!isset($printers[$dest])) {
|
||||
flash('err', 'No printer is available. Ask the person who runs this server.');
|
||||
} elseif ($pages !== '' && !preg_match('/^\d+(-\d+)?(,\d+(-\d+)?)*$/', $pages)) {
|
||||
flash('err', 'Pages should look like 1-3,5 — leave empty to print everything.');
|
||||
} elseif ($reprint !== '') {
|
||||
$path = stored_path($reprint);
|
||||
if ($path === null) { flash('err', 'That file is no longer in the folder.'); }
|
||||
else { @touch($path); send_job($path, $dest, $copies, $pages, $duplex, $mono); }
|
||||
} elseif (!$names || (string)$names[0] === '') {
|
||||
flash('err', 'Choose at least one file first.');
|
||||
} elseif (count($names) > MAX_FILES) {
|
||||
flash('err', 'Please send at most ' . MAX_FILES . ' files at a time.');
|
||||
} elseif (!storage_ready()) {
|
||||
flash('err', 'The save folder is missing or not writable: ' . STORE_DIR);
|
||||
} else {
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
foreach ($names as $i => $orig) {
|
||||
$orig = (string)$orig;
|
||||
$label = '“' . cut($orig, 60) . '”';
|
||||
$tmp = (string)$_FILES['files']['tmp_name'][$i];
|
||||
$errc = (int)$_FILES['files']['error'][$i];
|
||||
if ($errc !== UPLOAD_ERR_OK) { flash('err', "$label " . upload_error($errc) . '.'); continue; }
|
||||
|
||||
$conv = ext_convert($tmp, $orig); // Office files become PDFs (see extensions.php)
|
||||
if ($conv !== null) {
|
||||
if (isset($conv['error'])) { flash('err', "$label " . $conv['error']); continue; }
|
||||
$tmp = $conv['path']; $orig = $conv['name']; $label = '“' . cut($orig, 60) . '”';
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||
if (!isset(ALLOWED[$ext])) {
|
||||
$hint = in_array($ext, ['heic', 'heif'], true) ? ' Photos in HEIC format must be shared as JPEG.' : '';
|
||||
flash('err', "$label can’t be printed here (accepted: PDF, TXT, JPG, PNG, GIF)." . $hint);
|
||||
continue;
|
||||
}
|
||||
if (!in_array($finfo->file($tmp), ALLOWED[$ext], true)) { flash('err', "$label doesn’t look like a real .$ext file."); continue; }
|
||||
|
||||
$path = unique_path(safe_name($orig, $ext));
|
||||
if (!($conv !== null ? @rename($tmp, $path) : move_uploaded_file($tmp, $path))) { flash('err', "$label could not be saved on the server."); continue; }
|
||||
@chmod($path, 0664);
|
||||
send_job($path, $dest, $copies, $pages, $duplex, $mono);
|
||||
}
|
||||
}
|
||||
}
|
||||
$qs = [];
|
||||
if ($q !== '') { $qs[] = 'q=' . rawurlencode($q); }
|
||||
if ($admin) { $qs[] = 'admin'; }
|
||||
$loc = (strtok($_SERVER['REQUEST_URI'], '?') ?: '/') . ($qs ? '?' . implode('&', $qs) : '');
|
||||
header('Location: ' . $loc, true, 303);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- Page data ----------
|
||||
$printers = printers();
|
||||
$default = PRINTER !== '' ? PRINTER : default_printer();
|
||||
if (!isset($printers[$default])) { $default = (string)(array_key_first($printers) ?? ''); }
|
||||
$jobs = SHOW_QUEUE ? jobs() : [];
|
||||
$flashes = $_SESSION['flash'] ?? [];
|
||||
unset($_SESSION['flash']);
|
||||
$state = $printers[$default] ?? 'Offline';
|
||||
$files = list_files($q, $q === '' ? RECENT_COUNT : 50);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title><?= h(SITE_TITLE) ?></title>
|
||||
<style>
|
||||
:root{--bg:#eef0f3;--card:#fff;--ink:#16202b;--mute:#5d6b7a;--line:#d5dae1;--accent:#1d4ed8;--accent-ink:#fff;--ok:#157347;--okbg:#e4f4ea;--err:#b3261e;--errbg:#fbe9e7;--r:14px}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#10151b;--card:#1a212a;--ink:#e8edf2;--mute:#9aa8b6;--line:#2c3742;--accent:#6e9bff;--accent-ink:#0b1220;--ok:#6fd49a;--okbg:#14301f;--err:#ff9188;--errbg:#3a1b19}}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
|
||||
main{max-width:520px;margin:0 auto}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:8px 2px 16px}
|
||||
h1{font-size:1.5rem;margin:0;letter-spacing:-.01em}
|
||||
.pill{font-size:.85rem;padding:3px 11px;border-radius:99px;border:1px solid var(--line);color:var(--mute);white-space:nowrap}
|
||||
.pill::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;background:var(--mute)}
|
||||
.pill.Ready::before{background:var(--ok)}.pill.Printing::before{background:var(--accent)}.pill.Offline::before{background:var(--err)}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:18px;margin-bottom:16px}
|
||||
.msg{border-radius:10px;padding:10px 14px;margin-bottom:10px;font-size:.95rem}
|
||||
.msg.ok{background:var(--okbg);color:var(--ok)}.msg.err{background:var(--errbg);color:var(--err)}
|
||||
.drop{position:relative;display:block;border:2px dashed var(--line);border-radius:var(--r);padding:30px 16px;text-align:center;color:var(--mute);transition:border-color .15s,background .15s}
|
||||
.drop:hover,.drop:focus-within,.drop.over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,transparent)}
|
||||
.drop input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;font-size:0}
|
||||
.drop strong{display:block;color:var(--ink);font-size:1.1rem;margin-bottom:2px}
|
||||
.drop svg{width:34px;height:34px;stroke:var(--accent);fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;margin-bottom:8px}
|
||||
#picked{list-style:none;margin:12px 0 0;padding:0;font-size:.92rem;overflow-wrap:anywhere}
|
||||
#picked li{padding:4px 0;border-bottom:1px solid var(--line)}#picked li:last-child{border:0}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}
|
||||
.full{grid-column:1/-1}
|
||||
label.f{display:block;font-size:.9rem;color:var(--mute)}
|
||||
input[type=number],input[type=text],select{width:100%;margin-top:4px;padding:11px 12px;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:10px}
|
||||
input:focus-visible,select:focus-visible,button:focus-visible,.drop:focus-within{outline:3px solid color-mix(in srgb,var(--accent) 55%,transparent);outline-offset:2px}
|
||||
.chk{display:flex;align-items:center;gap:10px;min-height:44px;color:var(--ink);font-size:1rem}
|
||||
.chk input{width:22px;height:22px;accent-color:var(--accent)}
|
||||
button{font:inherit;cursor:pointer}
|
||||
.go{width:100%;margin-top:18px;padding:15px;border:0;border-radius:12px;background:var(--accent);color:var(--accent-ink);font-weight:600;font-size:1.08rem}
|
||||
.go:disabled{opacity:.6;cursor:progress}
|
||||
h2{font-size:1rem;margin:0 0 8px}
|
||||
.job{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:.92rem}
|
||||
.job:first-of-type{border-top:0}.job small{color:var(--mute)}
|
||||
.job button{padding:7px 14px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--err)}
|
||||
.mute{color:var(--mute);font-size:.88rem;margin:0}
|
||||
.search{display:flex;gap:8px;margin-bottom:8px}
|
||||
.search input{flex:1;min-width:0;padding:10px 12px;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:10px}
|
||||
.mini{display:inline-block;padding:9px 14px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--ink);font:inherit;text-decoration:none}
|
||||
.mini.p{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
||||
.mini.del{color:var(--err)}
|
||||
.file{display:flex;align-items:center;gap:8px;padding:10px 0;border-top:1px solid var(--line)}
|
||||
.fn{flex:1;min-width:0;overflow-wrap:anywhere;font-size:.95rem}
|
||||
.fn small{display:block;color:var(--mute)}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
<?= ext_css() ?>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1><?= h(SITE_TITLE) ?></h1>
|
||||
<?php if ($default !== ''): ?><span class="pill <?= h($state) ?>"><?= h(str_replace('_', ' ', $default)) ?> · <?= h($state) ?></span><?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php foreach ($flashes as [$type, $text]): ?>
|
||||
<div class="msg <?= $type === 'ok' ? 'ok' : 'err' ?>" role="status"><?= h($text) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$printers): ?>
|
||||
<div class="card"><h2>No printer found</h2><p class="mute">The server can’t see any printer. Check that CUPS is running and a printer is added.</p></div>
|
||||
<?php else: ?>
|
||||
<form class="card" method="post" enctype="multipart/form-data" id="f">
|
||||
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="q" value="<?= h($q) ?>">
|
||||
<label class="drop" id="drop">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9V3h12v6M6 18H4a1 1 0 0 1-1-1v-6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/></svg>
|
||||
<strong>Choose files to print</strong>
|
||||
PDF, text<?= $admin ? ', photo' : '' ?><?= h(ext_hint()) ?> · up to <?= MAX_MB ?> MB
|
||||
<input type="file" name="files[]" id="files" multiple accept=".pdf,.txt,application/pdf,text/plain<?= h(ext_accept() . ($admin ? ',.jpg,.jpeg,.png,.gif' : '')) ?>" aria-label="Choose files to print">
|
||||
</label>
|
||||
<ul id="picked" aria-live="polite"></ul>
|
||||
|
||||
<div class="grid">
|
||||
<?php if (PRINTER === '' && count($printers) > 1): ?>
|
||||
<label class="f full">Printer
|
||||
<select name="printer">
|
||||
<?php foreach ($printers as $name => $st): ?>
|
||||
<option value="<?= h($name) ?>"<?= $name === $default ? ' selected' : '' ?>><?= h(str_replace('_', ' ', $name)) ?> (<?= h($st) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
<label class="f">Copies
|
||||
<input type="number" name="copies" value="1" min="1" max="<?= MAX_COPIES ?>" inputmode="numeric">
|
||||
</label>
|
||||
<label class="f">Pages (optional)
|
||||
<input type="text" name="pages" placeholder="e.g. 1-3,5" inputmode="text" autocomplete="off" autocapitalize="off">
|
||||
</label>
|
||||
<label class="chk"><input type="checkbox" name="duplex" value="1"> Both sides</label>
|
||||
<label class="chk"><input type="checkbox" name="mono" value="1"> Black & white</label>
|
||||
<?= ext_form_html() ?>
|
||||
<?php if ($admin): ?><label class="chk full"><input type="checkbox" name="actual" value="1"> Images at actual size (not fit to page)</label><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button class="go" type="submit" id="go">Print</button>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2><?= $q === '' ? 'Recent files' : 'Search results' ?></h2>
|
||||
<form class="search" method="get" role="search">
|
||||
<?php if ($admin): ?><input type="hidden" name="admin" value=""><?php endif; ?>
|
||||
<input type="search" name="q" value="<?= h($q) ?>" placeholder="Search saved files" aria-label="Search saved files" autocomplete="off">
|
||||
<button type="submit" class="mini">Search</button>
|
||||
<?php if ($q !== ''): ?><a class="mini" href="?<?= $admin ? 'admin' : '' ?>">Clear</a><?php endif; ?>
|
||||
</form>
|
||||
<?php if (!$files['items']): ?>
|
||||
<p class="mute"><?= $q === '' ? 'Files you print are saved here so you can print them again.' : 'No saved files match “' . h($q) . '”.' ?></p>
|
||||
<?php else: ?>
|
||||
<p class="mute">Print uses the copies and options above.</p>
|
||||
<?php foreach ($files['items'] as $fl): ?>
|
||||
<div class="file">
|
||||
<span class="fn"><?= h($fl['name']) ?><small><?= h(date('M j, Y g:i a', $fl['time'])) ?> · <?= h(fmt_size($fl['size'])) ?></small></span>
|
||||
<?= ext_file_html($fl['name']) ?>
|
||||
<button class="mini p" type="submit" form="f" name="reprint" value="<?= h($fl['name']) ?>">Print</button>
|
||||
<?php if ($admin): ?><button class="mini del" type="submit" form="del" name="file" value="<?= h($fl['name']) ?>" onclick="return confirm('Delete this saved file?')">Delete</button><?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($files['total'] > count($files['items'])): ?>
|
||||
<p class="mute">Showing <?= count($files['items']) ?> of <?= (int)$files['total'] ?><?= $q === '' ? ' — search to find older files.' : ' — refine your search.' ?></p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($admin): ?>
|
||||
<form id="del" method="post">
|
||||
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="q" value="<?= h($q) ?>">
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if (SHOW_QUEUE && $jobs): ?>
|
||||
<section class="card">
|
||||
<h2>Waiting to print</h2>
|
||||
<?php foreach ($jobs as $j): ?>
|
||||
<form class="job" method="post">
|
||||
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="action" value="cancel">
|
||||
<input type="hidden" name="job" value="<?= h($j['id']) ?>">
|
||||
<span><?= h($j['id']) ?><br><small><?= h($j['user']) ?> · <?= (int)$j['kb'] ?> KB</small></span>
|
||||
<button type="submit" aria-label="Cancel job <?= h($j['id']) ?>">Cancel</button>
|
||||
</form>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="mute">Printed files are saved on the server so you can print them again.</p>
|
||||
</main>
|
||||
<script>
|
||||
(function(){
|
||||
var f=document.getElementById('f'); if(!f) return;
|
||||
var input=document.getElementById('files'), list=document.getElementById('picked'),
|
||||
go=document.getElementById('go'), drop=document.getElementById('drop');
|
||||
input.addEventListener('change',function(){
|
||||
list.innerHTML='';
|
||||
Array.prototype.forEach.call(input.files,function(file){
|
||||
var li=document.createElement('li');
|
||||
li.textContent=file.name+' ('+Math.max(1,Math.round(file.size/1024))+' KB)';
|
||||
list.appendChild(li);
|
||||
});
|
||||
});
|
||||
['dragenter','dragover'].forEach(function(e){input.addEventListener(e,function(){drop.classList.add('over')})});
|
||||
['dragleave','drop'].forEach(function(e){input.addEventListener(e,function(){drop.classList.remove('over')})});
|
||||
f.addEventListener('submit',function(){ go.disabled=true; go.textContent='Sending…'; });
|
||||
window.addEventListener('pageshow',function(){ go.disabled=false; go.textContent='Print'; });
|
||||
})();
|
||||
</script>
|
||||
<?= ext_page_html() ?>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue