First dev version v.0.1
This commit is contained in:
116
src/css/admin.css
Normal file
116
src/css/admin.css
Normal file
@@ -0,0 +1,116 @@
|
||||
/* css/admin.css — "Control Room" style */
|
||||
:root {
|
||||
--admin-bg: radial-gradient(circle at 50% 20%, var(--card), var(--bg));
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
body.light-mode {
|
||||
--admin-bg: radial-gradient(circle at 50% 20%, var(--card-light), var(--bg-light));
|
||||
}
|
||||
|
||||
#adminPage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
background: var(--admin-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 2rem 2.5rem;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.admin-container h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#adminStatus {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--muted-text, #aaa);
|
||||
}
|
||||
|
||||
#adminLoginForm,
|
||||
#adminLogout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#adminLoginForm input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #666;
|
||||
background: var(--bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#adminLoginForm button,
|
||||
#logoutBtn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
#adminLoginForm button:hover,
|
||||
#logoutBtn:hover {
|
||||
background: var(--accent-hover, #ff7b00);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-output {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg);
|
||||
color: var(--text-color);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px var(--shadow);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error { color: #ff5252; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
#adminLoginForm {
|
||||
flex-direction: column;
|
||||
}
|
||||
#adminLoginForm input,
|
||||
#adminLoginForm button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
15
src/css/gallery.css
Normal file
15
src/css/gallery.css
Normal file
@@ -0,0 +1,15 @@
|
||||
/* gallery.css – Phase 2 */
|
||||
.gallery-grid {display: grid;grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));gap: 12px;}
|
||||
.gallery-grid a {position: relative;overflow: hidden;border-radius: 8px;box-shadow: 0 2px 6px var(--shadow);background: #000;}
|
||||
.thumb {width: 100%;height: auto;opacity: 0;transition: opacity .4s ease;}
|
||||
.thumb.loaded {opacity: 1;}
|
||||
.download-link {display: inline-block;margin-top: 8px;background: var(--accent);color: #fff;padding: 8px 16px;border-radius: 8px;text-decoration: none;}
|
||||
.download-link:hover {background: var(--accentHover);}
|
||||
.exif {margin-top: 8px;font-size: 13px;color: var(--muted);}
|
||||
.hidden {display: none;}
|
||||
.gdesc-inner {background: var(--card);}
|
||||
.gdesc-inner .gslide-title {color: dimgray !important;}
|
||||
.gdesc-inner .exif {column-count: 2;column-gap: 2em;margin-top: 0.75em;font-size: 0.9em;line-height: 1.5;max-width: 600px;}
|
||||
.gdesc-inner .exif br {content: "";display: block;margin-bottom: 0.2em;}
|
||||
.gdesc-inner .exif span.label {font-weight: 600;color: #444;}
|
||||
.gdesc-inner .exif span.value {color: #666;}
|
||||
74
src/css/home.css
Normal file
74
src/css/home.css
Normal file
@@ -0,0 +1,74 @@
|
||||
#homePage {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#homePage h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 6px var(--shadow);
|
||||
padding: 15px;
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Progress bar styling */
|
||||
.progress-bar {
|
||||
width: 80%;
|
||||
height: 8px;
|
||||
background: var(--card);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar > div {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #4caf50;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Colored usage thresholds */
|
||||
.stat-card p.low { color: #4caf50; }
|
||||
.stat-card p.medium { color: #fbc02d; }
|
||||
.stat-card p.high { color: #e53935; }
|
||||
59
src/css/style.css
Normal file
59
src/css/style.css
Normal file
@@ -0,0 +1,59 @@
|
||||
:root{
|
||||
--bg:#0f1113; --card:#181a1c; --text:#e6e6e6; --muted:#9aa0a6;
|
||||
--accent:#3b82f6; --accentHover:#2563eb; --shadow: rgba(0,0,0,0.5);
|
||||
}
|
||||
body.light-mode{ --bg:#f7f7f8; --card:#fff; --text:#111; --muted:#666; --accent:#007bff; --accentHover:#0056b3; --shadow: rgba(0,0,0,0.08); }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:Inter, "Segoe UI", Roboto, Arial, sans-serif;background:var(--bg);color:var(--text);line-height:1.4}
|
||||
.site-header{background:var(--card);border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||
.header-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:16px;padding:14px 18px}
|
||||
.brand h1{margin:0;font-size:1.05rem}
|
||||
.brand .muted{display:block;font-size:12px;color:var(--muted)}
|
||||
.nav{display:flex;gap:8px;margin-left:12px}
|
||||
.nav-btn{background:transparent;border:1px solid rgba(255,255,255,0.04);color:var(--text);padding:8px 10px;border-radius:8px;cursor:pointer}
|
||||
.nav-btn:hover{background:rgba(255,255,255,0.02)}
|
||||
.nav-btn.active {background: var(--accent);color: #fff;border-color: var(--accent);}
|
||||
.nav-btn.active:hover {background: var(--accentHover);}
|
||||
.controls{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.search{padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.04);background:transparent;color:var(--text);min-width:180px}
|
||||
.theme-btn{background:var(--accent);border:none;color:#fff;padding:8px;border-radius:8px;cursor:pointer}
|
||||
|
||||
.content{max-width:1200px;margin:20px auto;padding:0 18px;min-height:60vh}
|
||||
.center{text-align:center;padding:40px 0}
|
||||
.loading{color:var(--muted)}
|
||||
.error{color:#ff6b6b}
|
||||
|
||||
/* footer */
|
||||
.site-footer{max-width:1200px;margin:30px auto 60px;padding:0 18px;text-align:center;color:var(--muted)}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width:720px){
|
||||
.header-inner{flex-direction:column;align-items:flex-start;gap:10px}
|
||||
.nav{order:3;width:100%;justify-content:space-between}
|
||||
.controls{width:100%;justify-content:space-between}
|
||||
.search{min-width:0;flex:1}
|
||||
}
|
||||
|
||||
/* Admin lock button */
|
||||
.lock-btn {
|
||||
font-size: 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, color 0.2s;
|
||||
}
|
||||
.lock-btn:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.lock-btn.locked {
|
||||
color: var(--muted);
|
||||
}
|
||||
.lock-btn.unlocked {
|
||||
color: #00ff7f; /* nice green glow */
|
||||
}
|
||||
img.emoji {
|
||||
width: 1.1em; /* scales relative to text size */
|
||||
height: 1.1em; /* keeps them square */
|
||||
vertical-align: -0.15em; /* nudges them down to line up nicely */
|
||||
}
|
||||
63
src/css/upload.css
Normal file
63
src/css/upload.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.upload-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 70vh;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.upload-page h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-page p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#uploadForm {
|
||||
background: var(--card);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
#uploadInput {
|
||||
color: var(--text);
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 18px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
background: var(--accentHover);
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
1
src/dist/css/glightbox.min.css
vendored
Normal file
1
src/dist/css/glightbox.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/dist/js/glightbox.min.js
vendored
Normal file
1
src/dist/js/glightbox.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
49
src/includes/admin_actions.php
Normal file
49
src/includes/admin_actions.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// includes/admin_actions.php
|
||||
require_once __DIR__ . '/config.php';
|
||||
session_start();
|
||||
|
||||
if (empty($_SESSION['is_admin'])) {
|
||||
http_response_code(403);
|
||||
exit('Access denied');
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
switch ($action) {
|
||||
case 'rebuild_gallery':
|
||||
require_once __DIR__ . '/api.php';
|
||||
echo "✅ Gallery cache rebuilt successfully.";
|
||||
break;
|
||||
|
||||
case 'rebuild_exif':
|
||||
require_once __DIR__ . '/exif_helper.php';
|
||||
$dir = $CONFIG['gallery_dir'];
|
||||
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
|
||||
$count = 0;
|
||||
foreach ($rii as $file) {
|
||||
if ($file->isFile() && preg_match('/\.jpe?g$/i', $file->getFilename())) {
|
||||
get_exif_cached($file->getPathname(), $CONFIG['exif_dir'], 0);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
echo "✅ Rebuilt EXIF cache for {$count} images.";
|
||||
break;
|
||||
|
||||
case 'clean_thumbs':
|
||||
$thumb_dir = $CONFIG['thumb_dir'];
|
||||
$removed = 0;
|
||||
foreach (glob($thumb_dir . '/*.jpg') as $t) {
|
||||
unlink($t);
|
||||
$removed++;
|
||||
}
|
||||
echo "🧹 Removed {$removed} thumbnails.";
|
||||
break;
|
||||
|
||||
case 'restart_php':
|
||||
shell_exec('pgrep php-fpm | xargs kill -USR2 2>/dev/null');
|
||||
echo "🔄 PHP-FPM reload signal sent.";
|
||||
break;
|
||||
|
||||
default:
|
||||
echo "Unknown action.";
|
||||
}
|
||||
32
src/includes/admin_auth.php
Normal file
32
src/includes/admin_auth.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// includes/admin_auth.php
|
||||
session_start();
|
||||
require_once __DIR__ . '/config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'check':
|
||||
echo json_encode(['logged_in' => !empty($_SESSION['is_admin'])]);
|
||||
break;
|
||||
|
||||
case 'login':
|
||||
if (password_verify($password, $CONFIG['upload_password'])) {
|
||||
$_SESSION['is_admin'] = true;
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
echo json_encode(['success' => false]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'logout':
|
||||
session_destroy();
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['error' => 'Invalid action']);
|
||||
}
|
||||
|
||||
84
src/includes/api.php
Normal file
84
src/includes/api.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
// includes/api.php – gallery JSON API with EXIF summary + permanent cache
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/exif_helper.php';
|
||||
require_once __DIR__ . '/thumbnail_helper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Pagination setup
|
||||
$page = max(1, intval($_GET['page'] ?? 1));
|
||||
$per_page = $CONFIG['max_per_page'] ?? 100;
|
||||
|
||||
// Cache file setup
|
||||
$cache_file = $CONFIG['cache_file'];
|
||||
|
||||
// Serve cached gallery JSON if fresh
|
||||
if (file_exists($cache_file)) {
|
||||
$data = json_decode(file_get_contents($cache_file), true);
|
||||
} else {
|
||||
$data = buildGalleryCache($CONFIG);
|
||||
if (!is_dir(dirname($cache_file))) @mkdir(dirname($cache_file), 0775, true);
|
||||
file_put_contents($cache_file, json_encode($data, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// Paginate
|
||||
$total = count($data['images']);
|
||||
$total_blocks = ceil($total / $per_page);
|
||||
$slice = array_slice($data['images'], ($page - 1) * $per_page, $per_page);
|
||||
|
||||
// Output JSON
|
||||
echo json_encode([
|
||||
'total_blocks' => $total_blocks,
|
||||
'blocks_returned' => count($slice),
|
||||
'blocks' => $slice
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
|
||||
// --- FUNCTIONS ---
|
||||
|
||||
function buildGalleryCache($CONFIG) {
|
||||
$dir = $CONFIG['gallery_dir'];
|
||||
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
|
||||
$images = [];
|
||||
|
||||
foreach ($rii as $file) {
|
||||
if ($file->isFile() && preg_match('/\.jpe?g$/i', $file->getFilename())) {
|
||||
$path = $file->getPathname();
|
||||
$hash = md5($path);
|
||||
$rel = str_replace($CONFIG['base_dir'], '', $path);
|
||||
$thumb = str_replace($CONFIG['base_dir'], '', $CONFIG['thumb_dir']) ."/" . $hash . '.' . strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
// Get EXIF (cached full dump)
|
||||
$info = [];
|
||||
$exif = [];
|
||||
$summary = [];
|
||||
$info = get_exif_cached($path, $CONFIG['exif_dir'], $hash);
|
||||
$exif_file = $info['file'];
|
||||
$exif = $info['info'];
|
||||
unset($info);
|
||||
$summary = extractExifSummary($exif);
|
||||
// Generate the thumbnails
|
||||
if (!file_exists($thumb)) {
|
||||
generate_thumbnail_imagick($path,$hash,$CONFIG['thumb_dir'],$CONFIG['thumb_max_size'],$CONFIG['thumb_quality']);
|
||||
}
|
||||
|
||||
$images[] = [
|
||||
'filename' => basename($path),
|
||||
'publicPath' => $rel,
|
||||
'thumbPublic' => $thumb,
|
||||
'date' => date('Y-m-d', filemtime($path)),
|
||||
'MD5' => $hash,
|
||||
'exif_file' => $exif_file,
|
||||
'exif' => $summary
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [
|
||||
'generated' => date('c'),
|
||||
'count' => count($images),
|
||||
'images' => $images
|
||||
];
|
||||
}
|
||||
|
||||
39
src/includes/config.php
Normal file
39
src/includes/config.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// includes/config.php
|
||||
// Global configuration for the site. Edit values as needed.
|
||||
$ROOT_DIR='/home/reclusejay/repos/camera-gallery/src';
|
||||
$CONFIG = [
|
||||
// === General Site Info ===
|
||||
'site_name' => 'My Photo Gallery',
|
||||
'nav_order' => ['home', 'gallery', 'upload'],
|
||||
'nav_admin' => ['shell', 'gethash'],
|
||||
'nav_hidden' => ['admin','infophp'],
|
||||
'default_theme' => 'dark', // 'dark' or 'light'
|
||||
'timezone' => 'UTC',
|
||||
|
||||
// === Paths ===
|
||||
'base_dir' => $ROOT_DIR,
|
||||
'gallery_dir' => $ROOT_DIR.'/img/sorted/jpg',
|
||||
'thumb_dir' => $ROOT_DIR.'/cache/thumbs',
|
||||
'cache_dir' => $ROOT_DIR.'/cache',
|
||||
'log_dir' => $ROOT_DIR.'/logs',
|
||||
'upload_dir' => $ROOT_DIR.'/img/uploads',
|
||||
'index_file' => $ROOT_DIR.'/img/.index',
|
||||
|
||||
// === Gallery & Caching ===
|
||||
'max_per_page' => 100,
|
||||
'thumb_max_size' => 300,
|
||||
'thumb_quality' => 80,
|
||||
'cache_file' => $ROOT_DIR.'/cache/gallery.json',
|
||||
'remove_nodes' => ['MAKERNOTE', 'INTEROP', 'THUMBNAIL'],
|
||||
'remove_tags' => ['SectionsFound','UserComment','UserCommentEncoding','Thumbnail.FileType','Thumbnail.MimeType','ComponentsConfiguration','FileSource','SceneType'],
|
||||
'exif_dir' => $ROOT_DIR.'/cache/exif',
|
||||
|
||||
// === Uploads ===
|
||||
'upload_password' => '$2y$12$Fb7u3H.428PoPCy/pxhqpu.poqjmDbiyzJtRJs/CEcCEPPMOYBLCm', // bcrypt hash
|
||||
'max_parallel_uploads' => 2, // max simultaneous uploads
|
||||
];
|
||||
|
||||
// Apply timezone globally
|
||||
date_default_timezone_set($CONFIG['timezone']);
|
||||
|
||||
123
src/includes/exif_helper.php
Normal file
123
src/includes/exif_helper.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
// includes/exif_helper.php
|
||||
// Full EXIF extractor and permanent cache
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function get_exif_cached($path, $exif_dir, $hash) {
|
||||
global $CONFIG;
|
||||
if (!file_exists($path)) return [];
|
||||
|
||||
$json_path = rtrim($exif_dir, '/') . '/' . $hash . '.json';
|
||||
|
||||
// Return cached EXIF if it exists (and expiry = 0 means never refresh)
|
||||
if (file_exists($json_path)) {
|
||||
$exif = json_decode(file_get_contents($json_path), true);
|
||||
if (is_array($exif)) return [ 'file' => $json_path, 'info' => $exif ];
|
||||
}
|
||||
|
||||
// Extract fresh EXIF
|
||||
$exif = @exif_read_data($path, null, true);
|
||||
|
||||
//clean exif data remove unwanted tags
|
||||
foreach ($CONFIG['remove_nodes'] as $node) {
|
||||
unset($exif[$node]);
|
||||
}
|
||||
|
||||
if (!$exif || !is_array($exif)) return [];
|
||||
// Clean up binary / overly large data
|
||||
foreach ($exif as $section => &$entries) {
|
||||
foreach ($entries as $key => $val) {
|
||||
if (is_string($val) && (strlen($val) > 256 || !mb_check_encoding($val, 'UTF-8'))) { unset($entries[$key]); }
|
||||
if (preg_match('/^UndefinedTag:0x[0-9A-Fa-f]+$/', $key)) { unset($entries[$key]); }
|
||||
if (in_array($key, $CONFIG['remove_tags'])) { unset($entries[$key]); }
|
||||
}
|
||||
}
|
||||
// Save the entire structure as JSON
|
||||
if (!is_dir($exif_dir)) @mkdir($exif_dir, 0775, true);
|
||||
file_put_contents($json_path, json_encode($exif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return [ 'file' => $json_path, 'info' => $exif ];
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
function extractExifSummary($exif) {
|
||||
if (!is_array($exif)) return [];
|
||||
|
||||
// Safely extract key EXIF tags if available
|
||||
return array_filter([
|
||||
'Camera' => $exif['IFD0']['MAKE'].'-'.$exif['IFD0']['Model'] ?? null,
|
||||
'Lens' => $exif['EXIF']['LensModel'] ?? null,
|
||||
'Aperture' => isset($exif['EXIF']['FNumber']) ? 'f/' . round(exifFractionToFloat($exif['EXIF']['FNumber']), 1) : null,
|
||||
'Shutter' => $exif['EXIF']['ExposureTime'] ?? null,
|
||||
'ISO' => $exif['EXIF']['ISOSpeedRatings'] ?? null,
|
||||
'WhiteBalance' => ($exif['EXIF']['WhiteBalance'] ?? 0) ? 'Manual' : 'Auto',
|
||||
'DateTaken' => $exif['EXIF']['DateTimeOriginal'] ?? null
|
||||
]);
|
||||
}*/
|
||||
|
||||
function extractExifSummary(array $exif): array {
|
||||
// Exif mapped values
|
||||
$programMap = [
|
||||
0 => 'Not defined', 1 => 'Manual', 2 => 'Normal program',
|
||||
3 => 'Aperture priority', 4 => 'Shutter priority', 5 => 'Creative program',
|
||||
6 => 'Action program', 7 => 'Portrait', 8 => 'Landscape',
|
||||
];
|
||||
$colorSpaceMap = [
|
||||
1 => 'sRGB', 65535 => 'AdobeRGB'
|
||||
];
|
||||
$meteringModeMap = [
|
||||
0 => 'Unknown', 1 => 'Average', 2 => 'Center-weighted', 3 => 'Spot', 4 => 'Multi-spot',
|
||||
5 => 'Pattern', 6 => 'Partial', 255 => 'Other'
|
||||
];
|
||||
$orientationMap = [
|
||||
1 => 'Top-left', 2 => 'Top-right', 3 => 'Bottom-right', 4 => 'Bottom-left',
|
||||
5 => 'Left-top', 6 => 'Right-top', 7 => 'Right-bottom', 8 => 'Left-bottom'
|
||||
];
|
||||
$sceneCaptureMap = [0 => 'Standard', 1 => 'Landscape', 2 => 'Portrait', 3 => 'Night scene'];
|
||||
|
||||
// Helper to convert EXIF fraction to float
|
||||
$fractionToFloat = function($val) {
|
||||
if (is_string($val) && strpos($val, '/') !== false) {
|
||||
[$num, $den] = explode('/', $val);
|
||||
return (float)$num / (float)$den;
|
||||
}
|
||||
return (float)$val;
|
||||
};
|
||||
|
||||
// Helper to safely get nested array values
|
||||
$get = function(array $arr, string ...$keys) {
|
||||
foreach ($keys as $key) {
|
||||
if (isset($arr[$key])) {
|
||||
$arr = $arr[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $arr;
|
||||
};
|
||||
|
||||
return array_filter([
|
||||
'Camera' => $get($exif, 'IFD0','Make').'-'.$get($exif, 'IFD0', 'Model'),
|
||||
'Lens' => $get($exif, 'EXIF', 'LensModel'),
|
||||
'ExposureProgram' => $programMap[$get($exif, 'EXIF', 'ExposureProgram')] ?? 0,
|
||||
'Aperture' => ($f = $get($exif, 'EXIF', 'FNumber')) ? 'f/' . round($fractionToFloat($f), 1) : null,
|
||||
'Shutter' => $get($exif, 'EXIF', 'ExposureTime'),
|
||||
'ISO' => $get($exif, 'EXIF', 'ISOSpeedRatings'),
|
||||
'FocalLength' => ($fl = $get($exif, 'EXIF', 'FocalLength')) ? round($fractionToFloat($fl), 1) . 'mm' : null,
|
||||
'ExposureBiasValue' => ($ev = $get($exif, 'EXIF', 'ExposureBiasValue')) ? round($fractionToFloat($ev), 1) . ' EV' : null,
|
||||
'WhiteBalance' => ($wb = $get($exif, 'EXIF', 'WhiteBalance') ?? 0) ? 'Manual' : 'Auto',
|
||||
'DateTaken' => $get($exif, 'EXIF', 'DateTimeOriginal'),
|
||||
'Orientation' => $orientationMap[$get($exif, 'IFD0', 'Orientation') ?? 1],
|
||||
'ImageWidth' => $get($exif, 'EXIF', 'PixelXDimension') ?? $get($exif, 'IFD0', 'ImageWidth'),
|
||||
'ImageHeight' => $get($exif, 'EXIF', 'PixelYDimension') ?? $get($exif, 'IFD0', 'ImageLength'),
|
||||
'ColorSpace' => $colorSpaceMap[$get($exif, 'EXIF', 'ColorSpace') ?? 65535],
|
||||
'MeteringMode' => $meteringModeMap[$get($exif, 'EXIF', 'MeteringMode') ?? 0],
|
||||
'SceneCaptureType' => $sceneCaptureMap[$get($exif, 'EXIF', 'SceneCaptureType') ?? 0],
|
||||
'SubjectDistance' => $get($exif, 'EXIF', 'SubjectDistance'),
|
||||
'Contrast' => $get($exif, 'EXIF', 'Contrast'),
|
||||
'Saturation' => $get($exif, 'EXIF', 'Saturation'),
|
||||
'Sharpness' => $get($exif, 'EXIF', 'Sharpness'),
|
||||
]);
|
||||
}
|
||||
|
||||
74
src/includes/stats.php
Normal file
74
src/includes/stats.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
// includes/stats.php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- CPU usage ---
|
||||
$load = sys_getloadavg()[0];
|
||||
$cpu_usage = round(($load / shell_exec('nproc')) * 100, 1);
|
||||
|
||||
// --- Memory usage ---
|
||||
$meminfo = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES);
|
||||
$mem = [];
|
||||
foreach ($meminfo as $line) {
|
||||
[$key, $val] = array_map('trim', explode(':', $line));
|
||||
$mem[$key] = (int) filter_var($val, FILTER_SANITIZE_NUMBER_INT);
|
||||
}
|
||||
$ram_total = $mem['MemTotal'] ?? 0;
|
||||
$ram_free = ($mem['MemAvailable'] ?? 0);
|
||||
$ram_used = $ram_total - $ram_free;
|
||||
$ram_usage = $ram_total ? round(($ram_used / $ram_total) * 100, 1) : 0;
|
||||
|
||||
$swap_total = $mem['SwapTotal'] ?? 0;
|
||||
$swap_free = $mem['SwapFree'] ?? 0;
|
||||
$swap_used = $swap_total - $swap_free;
|
||||
$swap_usage = $swap_total ? round(($swap_used / $swap_total) * 100, 1) : 0;
|
||||
|
||||
// Optional: check ZRAM usage
|
||||
$zram_used = 0;
|
||||
$zram_total = 0;
|
||||
if (is_dir('/sys/block')) {
|
||||
foreach (glob('/sys/block/zram*/mm_stat') as $zram) {
|
||||
$stats = explode(' ', file_get_contents($zram));
|
||||
$zram_used += (int)$stats[1];
|
||||
$zram_total += (int)$stats[2];
|
||||
}
|
||||
}
|
||||
$zram_usage = $zram_total ? round(($zram_used / $zram_total) * 100, 1) : 0;
|
||||
|
||||
// --- Disk usage ---
|
||||
$disk_total = disk_total_space('/');
|
||||
$disk_free = disk_free_space('/');
|
||||
$disk_used = $disk_total - $disk_free;
|
||||
$disk_usage = round(($disk_used / $disk_total) * 100, 1);
|
||||
$disk0_total = disk_total_space('/home');
|
||||
$disk0_free = disk_free_space('/home');
|
||||
$disk0_used = $disk0_total - $disk0_free;
|
||||
$disk0_usage = round(($disk0_used / $disk0_total) * 100, 1);
|
||||
|
||||
// --- Gallery image count ---
|
||||
// --- Image count from cache/gallery.json ---
|
||||
$jsonFile = __DIR__ . '/../cache/gallery.json';
|
||||
if (file_exists($jsonFile)) {
|
||||
$gallery = json_decode(file_get_contents($jsonFile), true);
|
||||
$count = $gallery['count'] ?? 0;
|
||||
} else {
|
||||
$count = 0;
|
||||
}
|
||||
|
||||
|
||||
// --- Response ---
|
||||
echo json_encode([
|
||||
'cpu' => $cpu_usage,
|
||||
'ram' => $ram_usage,
|
||||
'ram_total' => $ram_total * 1024, // convert KB → bytes
|
||||
'ram_used' => $ram_used * 1024,
|
||||
'zram' => $zram_usage,
|
||||
'swap' => $swap_usage,
|
||||
'disk' => $disk_usage,
|
||||
'disk_total' => $disk_total,
|
||||
'disk_used' => $disk_used,
|
||||
'disk0' => $disk0_usage,
|
||||
'disk0_total' => $disk0_total,
|
||||
'disk0_used' => $disk0_used,
|
||||
'images' => $count,
|
||||
]);
|
||||
90
src/includes/thumbnail_helper.php
Normal file
90
src/includes/thumbnail_helper.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/** includes/thumbnail_helper.php
|
||||
* Generate a thumbnail using Imagick
|
||||
* @param string $src_path Path to the source image
|
||||
* @param string $hash MD5 filename
|
||||
* @param string $thumb_path Path to save the generated thumbnail
|
||||
* @param int $max_size Max thumbnail size (pixels)
|
||||
* @param int $quality Output image quality (1–100)
|
||||
* @return bool True on success, false on failure
|
||||
*/
|
||||
|
||||
|
||||
function generate_thumbnail_imagick($src_path, $hash, $thumb_path, $max_size, $quality) {
|
||||
$ext = strtolower(pathinfo($src_path, PATHINFO_EXTENSION));
|
||||
$img = new Imagick($src_path);
|
||||
if (method_exists($img, 'autoOrientImage')) {
|
||||
// Modern Imagick versions
|
||||
$img->setImageOrientation(Imagick::ORIENTATION_UNDEFINED);
|
||||
$img->autoOrientImage();
|
||||
} else {
|
||||
// Manual fallback for older Imagick builds
|
||||
switch ($img->getImageOrientation()) {
|
||||
case Imagick::ORIENTATION_TOPLEFT: // normal
|
||||
break;
|
||||
case Imagick::ORIENTATION_TOPRIGHT:
|
||||
$img->flopImage(); // horizontal flip
|
||||
break;
|
||||
case Imagick::ORIENTATION_BOTTOMRIGHT:
|
||||
$img->rotateImage(new ImagickPixel(), 180);
|
||||
break;
|
||||
case Imagick::ORIENTATION_BOTTOMLEFT:
|
||||
$img->flopImage();
|
||||
$img->rotateImage(new ImagickPixel(), 180);
|
||||
break;
|
||||
case Imagick::ORIENTATION_LEFTTOP:
|
||||
$img->flopImage();
|
||||
$img->rotateImage(new ImagickPixel(), 90);
|
||||
break;
|
||||
case Imagick::ORIENTATION_RIGHTTOP:
|
||||
$img->rotateImage(new ImagickPixel(), 90);
|
||||
break;
|
||||
case Imagick::ORIENTATION_RIGHTBOTTOM:
|
||||
$img->flopImage();
|
||||
$img->rotateImage(new ImagickPixel(), 270);
|
||||
break;
|
||||
case Imagick::ORIENTATION_LEFTBOTTOM:
|
||||
$img->rotateImage(new ImagickPixel(), 270);
|
||||
break;
|
||||
}
|
||||
$img->setImageOrientation(Imagick::ORIENTATION_UNDEFINED);
|
||||
}
|
||||
|
||||
// Get original dimensions
|
||||
$width = $img->getImageWidth();
|
||||
$height = $img->getImageHeight();
|
||||
|
||||
// Calculate proportional scaling based on longest side
|
||||
if ($width > $height) {
|
||||
$new_width = $max_size;
|
||||
$new_height = (int) round($height * ($max_size / $width));
|
||||
} else {
|
||||
$new_height = $max_size;
|
||||
$new_width = (int) round($width * ($max_size / $height));
|
||||
}
|
||||
|
||||
$img->setImageCompressionQuality($quality);
|
||||
$img->setBackgroundColor(new ImagickPixel('white'));
|
||||
$img->thumbnailImage($new_width, $new_height, true);
|
||||
$img->stripImage(); // remove metadata (saves space)
|
||||
|
||||
// Infer output format from extension
|
||||
switch ($ext) {
|
||||
case 'png':
|
||||
$img->setImageFormat('png');
|
||||
break;
|
||||
case 'webp':
|
||||
$img->setImageFormat('webp');
|
||||
break;
|
||||
default:
|
||||
$img->setImageFormat('jpeg');
|
||||
break;
|
||||
}
|
||||
|
||||
$thumbnail=$thumb_path . '/' . $hash . '.' . $ext;
|
||||
|
||||
$img->writeImage($thumbnail);
|
||||
$img->destroy();
|
||||
|
||||
return true;
|
||||
}
|
||||
58
src/includes/upload_handler.php
Normal file
58
src/includes/upload_handler.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// includes/upload_handler.php – backend for multi-file upload
|
||||
require_once __DIR__ . '/config.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$uploadDir = '/srv/www/uploads';
|
||||
$indexFile = $CONFIG['index_file'];
|
||||
$successCount = 0;
|
||||
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
if (!isset($_FILES['files'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'No files uploaded.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($_FILES['files']['tmp_name'] as $i => $tmp) {
|
||||
if (!is_uploaded_file($tmp)) continue;
|
||||
|
||||
$origName = $_FILES['files']['name'][$i];
|
||||
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
|
||||
if ($ext !== 'jpg' && $ext !== 'jpeg') continue;
|
||||
|
||||
// Extract EXIF date + shutter count if available
|
||||
$exif = @exif_read_data($tmp, 'EXIF', true);
|
||||
$date = $exif['EXIF']['DateTimeOriginal'] ?? date('Y:m:d H:i:s');
|
||||
[$Y,$m,$d,$H,$M,$S] = sscanf($date, "%4d:%2d:%2d %2d:%2d:%2d");
|
||||
|
||||
$yearDir = sprintf("%s/%04d", $uploadDir, $Y);
|
||||
$monthDir = sprintf("%s/%02d", $yearDir, $m);
|
||||
$dayDir = sprintf("%s/%02d", $monthDir, $d);
|
||||
if (!is_dir($dayDir)) mkdir($dayDir, 0755, true);
|
||||
|
||||
$time = sprintf("%02d%02d%02d", $H, $M, $S);
|
||||
|
||||
$shutter = '';
|
||||
if (!empty($exif['EXIF']['ShutterCount'])) {
|
||||
$shutter = 'sc' . $exif['EXIF']['ShutterCount'];
|
||||
} elseif (!empty($exif['MakerNote']['ShutterCount'])) {
|
||||
$shutter = 'sc' . $exif['MakerNote']['ShutterCount'];
|
||||
} else {
|
||||
$shutter = 'rn' . rand(100000, 999999);
|
||||
}
|
||||
|
||||
// Read / increment index
|
||||
$idx = 1;
|
||||
if (file_exists($indexFile)) {
|
||||
$idx = intval(trim(file_get_contents($indexFile))) + 1;
|
||||
}
|
||||
file_put_contents($indexFile, $idx);
|
||||
|
||||
$newName = sprintf("%s_%sidx%d.%s", $time, $shutter, $idx, $ext);
|
||||
$dest = sprintf("%s/%s", $dayDir, $newName);
|
||||
|
||||
if (move_uploaded_file($tmp, $dest)) $successCount++;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'count' => $successCount]);
|
||||
120
src/index.php
Normal file
120
src/index.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
session_start();
|
||||
// index.php - SPA
|
||||
require_once __DIR__ . '/includes/config.php';
|
||||
|
||||
// Replicate what admin_auth.php does for the "check" action
|
||||
$logged_in = !empty($_SESSION['is_admin']);
|
||||
|
||||
// Auto-generate navigation items from pages/
|
||||
$pages_dir = __DIR__ . '/pages';
|
||||
$nav_order = $CONFIG['nav_order'] ?? [];
|
||||
$nav_admin = $CONFIG['nav_admin'] ?? [];
|
||||
$nav_hidden = $CONFIG['nav_hidden'] ?? [];
|
||||
$nav_items = [];
|
||||
$nav_bar = [];
|
||||
if (is_dir($pages_dir)) {
|
||||
foreach (glob($pages_dir . '/*.php') as $file) {
|
||||
$base = strtolower(basename($file, '.php'));
|
||||
if (in_array($base, ['_template', 'error'])) continue; // skip special files
|
||||
if (in_array($base, array_map('strtolower', $nav_hidden))) continue;
|
||||
$label = ucfirst($base);
|
||||
|
||||
if (in_array($base, array_map('strtolower', $nav_admin))) {
|
||||
if (!empty($_SESSION['is_admin'])) {
|
||||
$nav_bar_admin[] = $label;
|
||||
}
|
||||
} else {
|
||||
$nav_bar[] = $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Categorize pages
|
||||
foreach ($nav_items as $base => $label) {
|
||||
$lcBase = lcfirst($base);
|
||||
|
||||
if (in_array($lcBase, array_map('strtolower',$nav_hidden))) {
|
||||
continue; // skip hidden
|
||||
}
|
||||
|
||||
if (in_array($lcBase, $nav_admin)) {
|
||||
$nav_bar_admin[] = $label;
|
||||
} elseif (in_array($lcBase, $nav_order)) {
|
||||
$nav_bar[] = $label; // ordered pages
|
||||
} else {
|
||||
$nav_bar[] = $label; // unordered normal pages
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: order nav_bar according to $nav_order
|
||||
$ordered = [];
|
||||
foreach ($nav_order as $o) {
|
||||
foreach ($nav_bar as $key => $label) {
|
||||
if (strtolower($label) === $o) {
|
||||
$ordered[] = $label;
|
||||
unset($nav_bar[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$nav_bar = array_merge($ordered, $nav_bar);
|
||||
unset($nav_items,$nav_order,$nav_admin,$nav_hidden)
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title><?= htmlspecialchars($CONFIG['site_name']) ?></title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<h1><?= htmlspecialchars($CONFIG['site_name']) ?></h1>
|
||||
<small class="muted">Embedded gallery</small>
|
||||
</div>
|
||||
|
||||
<nav class="nav" id="navBar">
|
||||
<?php
|
||||
foreach ($nav_bar as $page) {
|
||||
echo '<button class="nav-btn" data-page="' . htmlspecialchars(lcfirst($page)) . '">' .
|
||||
htmlspecialchars($page) . '</button>';
|
||||
}
|
||||
if (isset($logged_in) && $logged_in === TRUE){
|
||||
foreach ($nav_bar_admin as $page) {
|
||||
echo '<button class="nav-btn" data-page="' . htmlspecialchars(lcfirst($page)) . '">' .
|
||||
htmlspecialchars($page) . '</button>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</nav>
|
||||
|
||||
<div class="controls">
|
||||
<input id="searchInput" class="search" placeholder="Search..." />
|
||||
<button id="adminLock" class="lock-btn" title="Admin Login">🔒</button>
|
||||
<button id="themeToggle" class="theme-btn" aria-label="Toggle theme">🌙</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main id="content" class="content" data-default-page="home">
|
||||
<div class="center">
|
||||
<p class="loading">Loading…</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<small>© <?= date('Y') ?> <?= htmlspecialchars($CONFIG['site_name']) ?></small>
|
||||
</footer>
|
||||
|
||||
<!-- GLightbox -->
|
||||
<link rel="stylesheet" href="dist/css/glightbox.min.css">
|
||||
<script src="dist/js/glightbox.min.js"></script>
|
||||
|
||||
|
||||
|
||||
<script src="js/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php unset($nav_bar,$nav_bar_admin,$logged_in); ?>
|
||||
85
src/js/admin.js
Normal file
85
src/js/admin.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// js/admin.js
|
||||
function initAdminPage() {
|
||||
const statusEl = document.getElementById("adminStatus");
|
||||
const loginForm = document.getElementById("adminLoginForm");
|
||||
const logoutBox = document.getElementById("adminLogout");
|
||||
const passwordInput = document.getElementById("adminPassword");
|
||||
const debug = document.getElementById("adminDebug");
|
||||
|
||||
function log(msg) {
|
||||
console.log("[admin]", msg);
|
||||
debug.textContent += msg + "\n";
|
||||
}
|
||||
|
||||
async function checkLogin() {
|
||||
statusEl.textContent = "Checking login status…";
|
||||
loginForm.style.display = "none";
|
||||
logoutBox.style.display = "none";
|
||||
try {
|
||||
const res = await fetch("includes/admin_auth.php?action=check", {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json();
|
||||
//log("Check response: " + JSON.stringify(data));
|
||||
if (data.logged_in) {
|
||||
statusEl.textContent = "✅ Logged in as admin";
|
||||
logoutBox.style.display = "block";
|
||||
} else {
|
||||
statusEl.textContent = "Please log in to continue.";
|
||||
loginForm.style.display = "block";
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = "⚠️ Login check failed.";
|
||||
//log("Check error: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const pw = passwordInput.value.trim();
|
||||
if (!pw) return alert("Enter password");
|
||||
try {
|
||||
const res = await fetch("includes/admin_auth.php", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ action: "login", password: pw }),
|
||||
});
|
||||
const data = await res.json();
|
||||
//log("Login response: " + JSON.stringify(data));
|
||||
if (data.success) {
|
||||
//alert("✅ Login successful");
|
||||
await checkLogin();
|
||||
window.location.reload();
|
||||
} else {
|
||||
//alert("❌ Wrong password");
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
log("Login error: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
||||
await fetch("includes/admin_auth.php", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ action: "logout" }),
|
||||
});
|
||||
// Refresh the whole page after logout
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
checkLogin();
|
||||
}
|
||||
|
||||
document.addEventListener("page-loaded", (e) => {
|
||||
if (e.detail.page === "admin") initAdminPage();
|
||||
});
|
||||
|
||||
// Fallback: if admin page already in DOM when this script loads, init immediately
|
||||
if (document.getElementById("adminPage")) {
|
||||
initAdminPage();
|
||||
}
|
||||
155
src/js/app.js
Normal file
155
src/js/app.js
Normal file
@@ -0,0 +1,155 @@
|
||||
// js/app.js - SPA loader + theme toggle + history + admin lock
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const content = document.getElementById('content');
|
||||
const navBtns = document.querySelectorAll('.nav-btn');
|
||||
const themeBtn = document.getElementById('themeToggle');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const defaultPage = content.dataset.defaultPage || 'gallery';
|
||||
|
||||
// --- Theme init ---
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'light') document.body.classList.add('light-mode');
|
||||
|
||||
function updateThemeLabel() {
|
||||
themeBtn.textContent = document.body.classList.contains('light-mode') ? '☀' : '🌙';
|
||||
}
|
||||
updateThemeLabel();
|
||||
|
||||
themeBtn.addEventListener('click', () => {
|
||||
const isLight = document.body.classList.toggle('light-mode');
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
updateThemeLabel();
|
||||
});
|
||||
|
||||
// --- Admin Lock Button ---
|
||||
(async function adminLockInit() {
|
||||
const lockBtn = document.getElementById('adminLock');
|
||||
if (!lockBtn) return;
|
||||
|
||||
async function checkAdmin() {
|
||||
try {
|
||||
const res = await fetch('includes/admin_auth.php?action=check');
|
||||
const json = await res.json();
|
||||
if (json.logged_in) {
|
||||
lockBtn.textContent = '🔓';
|
||||
lockBtn.classList.add('unlocked');
|
||||
lockBtn.classList.remove('locked');
|
||||
lockBtn.title = 'Click to log out';
|
||||
} else {
|
||||
lockBtn.textContent = '🔒';
|
||||
lockBtn.classList.add('locked');
|
||||
lockBtn.classList.remove('unlocked');
|
||||
lockBtn.title = 'Admin Login';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Admin check failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// On click — log out if logged in, or navigate to admin page if not
|
||||
lockBtn.addEventListener('click', async () => {
|
||||
if (lockBtn.classList.contains('unlocked')) {
|
||||
const ok = confirm('Log out of admin mode?');
|
||||
if (!ok) return;
|
||||
await fetch('includes/admin_auth.php', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ action: 'logout' })
|
||||
});
|
||||
await checkAdmin();
|
||||
alert('You have been logged out.');
|
||||
loadPage(defaultPage);
|
||||
} else {
|
||||
const event = new CustomEvent('navigate', { detail: { page: 'admin' } });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
await checkAdmin();
|
||||
setInterval(checkAdmin, 30000);
|
||||
})();
|
||||
|
||||
// --- Page loader ---
|
||||
async function loadPage(page, push = true) {
|
||||
content.innerHTML = '<div class="center"><p class="loading">Loading…</p></div>';
|
||||
try {
|
||||
const res = await fetch(`pages/${page}.php`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const html = await res.text();
|
||||
|
||||
// Insert the page HTML
|
||||
content.innerHTML = html;
|
||||
|
||||
// 🔹 Execute any inline <script> blocks from the loaded page
|
||||
content.querySelectorAll('script').forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
if (oldScript.src) newScript.src = oldScript.src;
|
||||
else newScript.textContent = oldScript.textContent;
|
||||
document.body.appendChild(newScript);
|
||||
newScript.remove();
|
||||
});
|
||||
|
||||
// 🔹 Optionally load external page-specific JS if it exists
|
||||
const pageJsPath = `js/${page}.js`;
|
||||
let jsLoaded = Promise.resolve();
|
||||
try {
|
||||
const jsCheck = await fetch(pageJsPath, { method: 'HEAD' });
|
||||
if (jsCheck.ok) {
|
||||
jsLoaded = new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `${pageJsPath}?v=${Date.now()}`;
|
||||
script.onload = resolve;
|
||||
script.onerror = resolve; // resolve even if load fails gracefully
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// no external JS found – fine
|
||||
}
|
||||
|
||||
// Wait for any JS to finish loading before firing event
|
||||
await jsLoaded;
|
||||
|
||||
if (push) history.pushState({ page }, '', `?page=${page}`);
|
||||
setActiveNav(page);
|
||||
|
||||
// ✅ Dispatch after page-specific JS and DOM are ready
|
||||
document.dispatchEvent(new CustomEvent('page-loaded', { detail: { page } }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
content.innerHTML = `<div class="center"><p class="error">Failed to load <strong>${page}</strong>: ${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nav buttons ---
|
||||
navBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => loadPage(btn.dataset.page));
|
||||
});
|
||||
|
||||
function setActiveNav(page) {
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.page === page);
|
||||
});
|
||||
}
|
||||
|
||||
// --- History navigation ---
|
||||
window.addEventListener('popstate', (e) => {
|
||||
const page = e.state?.page || new URLSearchParams(location.search).get('page') || defaultPage;
|
||||
loadPage(page, false);
|
||||
});
|
||||
|
||||
// --- Global search event ---
|
||||
searchInput.addEventListener('input', () => {
|
||||
document.dispatchEvent(new CustomEvent('global-search', { detail: { q: searchInput.value.trim() } }));
|
||||
});
|
||||
|
||||
// --- Handle "navigate" custom events ---
|
||||
window.addEventListener('navigate', e => {
|
||||
const page = e.detail?.page || defaultPage;
|
||||
loadPage(page);
|
||||
});
|
||||
|
||||
// --- Initial load ---
|
||||
const initPage = new URLSearchParams(location.search).get('page') || defaultPage;
|
||||
loadPage(initPage, false);
|
||||
});
|
||||
|
||||
102
src/js/gallery.js
Normal file
102
src/js/gallery.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// gallery.js – Phase 2: infinite scroll + lightbox
|
||||
|
||||
document.addEventListener('page-loaded', e => {
|
||||
if (e.detail.page !== 'gallery') return;
|
||||
if (window.galleryInit) return;
|
||||
window.galleryInit = true;
|
||||
|
||||
const grid = document.getElementById('galleryContainer');
|
||||
const loading = document.getElementById('galleryLoading');
|
||||
const end = document.getElementById('galleryEnd');
|
||||
let page = 1, loadingNow = false, totalBlocks = null;
|
||||
|
||||
const lightbox = GLightbox({ selector: '.glightbox' });
|
||||
|
||||
async function loadPage(p) {
|
||||
if (loadingNow) return;
|
||||
loadingNow = true;
|
||||
loading.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch(`includes/api.php?page=${p}`);
|
||||
if (!res.ok) throw new Error('Network error');
|
||||
const json = await res.json();
|
||||
totalBlocks = json.total_blocks;
|
||||
if (json.blocks_returned === 0) {
|
||||
end.classList.remove('hidden');
|
||||
loading.classList.add('hidden');
|
||||
loadingNow = false;
|
||||
return;
|
||||
}
|
||||
appendImages(json.blocks);
|
||||
page++;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.classList.add('hidden');
|
||||
loadingNow = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendImages(blocks) {
|
||||
blocks.forEach(img => {
|
||||
const a = document.createElement('a');
|
||||
a.href = img.publicPath;
|
||||
a.className = 'glightbox';
|
||||
a.setAttribute(
|
||||
'data-glightbox',
|
||||
`title:${escapeHtml(img.filename)};description:${buildCaption(img)}`
|
||||
);
|
||||
|
||||
const im = document.createElement('img');
|
||||
im.loading = 'lazy';
|
||||
im.dataset.src = img.thumbPublic;
|
||||
im.alt = img.filename;
|
||||
im.classList.add('thumb');
|
||||
a.appendChild(im);
|
||||
grid.appendChild(a);
|
||||
});
|
||||
observeThumbs();
|
||||
lightbox.reload();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return (s + '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])
|
||||
);
|
||||
}
|
||||
|
||||
function buildCaption(img) {
|
||||
let html = `<a href="${img.publicPath}" download class="download-link">⬇ Download</a>`;
|
||||
if (img.exif && Object.keys(img.exif).length) {
|
||||
html += '<div class="exif">';
|
||||
for (const [k, v] of Object.entries(img.exif)) html += `<span class="label">${k}:</span> <span class="value">${v}</span><br>`;
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(ent => {
|
||||
if (ent.isIntersecting) {
|
||||
const img = ent.target;
|
||||
if (img.dataset.src) {
|
||||
img.src = img.dataset.src;
|
||||
img.onload = () => img.classList.add('loaded');
|
||||
observer.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '200px' });
|
||||
|
||||
function observeThumbs() {
|
||||
grid.querySelectorAll('img[data-src]').forEach(im => observer.observe(im));
|
||||
}
|
||||
|
||||
window.galleryScrollHandler ??= () => {
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 800)
|
||||
loadPage(page);
|
||||
};
|
||||
window.addEventListener('scroll', window.galleryScrollHandler, { passive: true });
|
||||
|
||||
loadPage(page);
|
||||
});
|
||||
111
src/js/home.js
Normal file
111
src/js/home.js
Normal file
@@ -0,0 +1,111 @@
|
||||
document.addEventListener('page-loaded', e => {
|
||||
if (e.detail.page !== "home") return;
|
||||
|
||||
const stats = ['Cpu','Ram','Zram','Swap','Disk','Disk0','Images'];
|
||||
const elements = {};
|
||||
const bars = {};
|
||||
const prev = {};
|
||||
const textElements = {
|
||||
'Ram': document.getElementById('statRamText'),
|
||||
'Disk': document.getElementById('statDiskText'),
|
||||
'Disk0': document.getElementById('statDisk0Text')
|
||||
};
|
||||
|
||||
stats.forEach(k => {
|
||||
elements[k] = document.getElementById('stat'+k);
|
||||
bars[k] = document.getElementById('bar'+k);
|
||||
prev[k] = 0;
|
||||
});
|
||||
|
||||
fetchStats();
|
||||
setInterval(fetchStats, 2000);
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch("includes/stats.php", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
|
||||
stats.forEach(key => {
|
||||
let val = data[key.toLowerCase()];
|
||||
if (val === undefined || val === null) return;
|
||||
|
||||
const numVal = Number(val) || 0;
|
||||
|
||||
// Animate number
|
||||
if (key === 'Images') {
|
||||
// Raw number
|
||||
animateValue(elements[key], prev[key], numVal, 400, false);
|
||||
} else {
|
||||
// Percentage display
|
||||
animateValue(elements[key], prev[key], numVal, 400, true);
|
||||
}
|
||||
prev[key] = numVal;
|
||||
|
||||
if (textElements['Ram'] && data.ram_used !== undefined && data.ram_total) {
|
||||
textElements['Ram'].textContent = `${formatBytes(data.ram_used)} / ${formatBytes(data.ram_total)}`;
|
||||
}
|
||||
|
||||
if (textElements['Disk'] && data.disk_used !== undefined && data.disk_total) {
|
||||
textElements['Disk'].textContent = `${formatBytes(data.disk_used)} / ${formatBytes(data.disk_total)}`;
|
||||
}
|
||||
if (textElements['Disk0'] && data.disk0_used !== undefined && data.disk0_total) {
|
||||
textElements['Disk0'].textContent = `${formatBytes(data.disk0_used)} / ${formatBytes(data.disk0_total)}`;
|
||||
}
|
||||
|
||||
|
||||
// Progress bar
|
||||
if (bars[key]) {
|
||||
let pct = key === 'Images' ? Math.min(numVal / 100 * 100, 100) : numVal;
|
||||
bars[key].style.width = pct + '%';
|
||||
}
|
||||
|
||||
// Color thresholds
|
||||
if (numVal >= 85) {
|
||||
elements[key].className = 'high';
|
||||
} else if (numVal >= 75) {
|
||||
elements[key].className = 'medium';
|
||||
} else {
|
||||
elements[key].className = 'low';
|
||||
}
|
||||
});
|
||||
|
||||
// Add memory and disk text
|
||||
if (data.ram_total && data.ram_used !== undefined) {
|
||||
elements['Ram'].textContent += ` (${formatBytes(data.ram_used)} / ${formatBytes(data.ram_total)})`;
|
||||
}
|
||||
if (data.disk_total && data.disk_used !== undefined) {
|
||||
elements['Disk'].textContent += ` (${formatBytes(data.disk_used)} / ${formatBytes(data.disk_total)})`;
|
||||
}
|
||||
if (data.disk0_total && data.disk0_used !== undefined) {
|
||||
elements['Disk0'].textContent += ` (${formatBytes(data.disk0_used)} / ${formatBytes(data.disk0_total)})`;
|
||||
}
|
||||
|
||||
} catch(err) {
|
||||
console.warn("Failed to fetch stats:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function animateValue(el, start, end, duration, showPercent = true) {
|
||||
let startTime = null;
|
||||
|
||||
function step(timestamp) {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
const current = start + (end - start) * progress;
|
||||
el.textContent = Math.round(current) + (showPercent ? '%' : '');
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0B';
|
||||
const k = 1024;
|
||||
const sizes = ['B','KB','MB','GB','TB'];
|
||||
const i = Math.floor(Math.log(bytes)/Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k,i)).toFixed(1)) + sizes[i];
|
||||
}
|
||||
});
|
||||
119
src/js/upload.js
Normal file
119
src/js/upload.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// upload.js – parallel uploader with concurrency limit
|
||||
document.addEventListener('page-loaded', e => {
|
||||
if (e.detail.page !== 'upload') return;
|
||||
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const selectBtn = document.getElementById('selectBtn');
|
||||
const statusDiv = document.getElementById('uploadStatus');
|
||||
const uploadList = document.getElementById('uploadList');
|
||||
|
||||
// concurrency limit pulled from a global config variable injected by PHP
|
||||
const MAX_PARALLEL = window.UPLOAD_LIMIT || 4;
|
||||
|
||||
selectBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', e => handleFiles(e.target.files));
|
||||
|
||||
dropZone.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('hover');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('hover'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('hover');
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
function createProgressItem(file) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'upload-item';
|
||||
item.innerHTML = `
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="progress-bar"><div class="progress-inner"></div></div>
|
||||
<div class="file-status">Waiting…</div>
|
||||
`;
|
||||
uploadList.appendChild(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
if (!files.length) return;
|
||||
uploadList.innerHTML = '';
|
||||
statusDiv.innerHTML = `<p>Uploading ${files.length} file(s)…</p>`;
|
||||
|
||||
const queue = Array.from(files);
|
||||
const results = [];
|
||||
let active = 0;
|
||||
|
||||
async function next() {
|
||||
if (queue.length === 0) return;
|
||||
const file = queue.shift();
|
||||
active++;
|
||||
const item = createProgressItem(file);
|
||||
const ok = await uploadFile(file, item);
|
||||
results.push(ok);
|
||||
active--;
|
||||
next(); // start next file when one finishes
|
||||
}
|
||||
|
||||
// Start up to MAX_PARALLEL uploads
|
||||
const starters = [];
|
||||
for (let i = 0; i < Math.min(MAX_PARALLEL, queue.length); i++) starters.push(next());
|
||||
|
||||
await Promise.all(starters);
|
||||
|
||||
const successCount = results.filter(r => r === true).length;
|
||||
const failCount = results.length - successCount;
|
||||
statusDiv.innerHTML = `<p class="ok">✅ Uploaded ${successCount} file(s), ${failCount} failed.</p>`;
|
||||
}
|
||||
|
||||
function uploadFile(file, item) {
|
||||
return new Promise(resolve => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const progress = item.querySelector('.progress-inner');
|
||||
const status = item.querySelector('.file-status');
|
||||
|
||||
xhr.open('POST', 'includes/upload_handler.php');
|
||||
const formData = new FormData();
|
||||
formData.append('files[]', file);
|
||||
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progress.style.width = percent + '%';
|
||||
status.textContent = percent + '%';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (json.success) {
|
||||
progress.style.width = '100%';
|
||||
status.textContent = '✅ Done';
|
||||
resolve(true);
|
||||
} else {
|
||||
status.textContent = '⚠️ Failed';
|
||||
resolve(false);
|
||||
}
|
||||
} catch {
|
||||
status.textContent = '⚠️ Parse error';
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
status.textContent = '❌ HTTP ' + xhr.status;
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
status.textContent = '❌ Network error';
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
});
|
||||
16
src/pages/admin.php
Normal file
16
src/pages/admin.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<section id="adminPage">
|
||||
<div class="admin-container">
|
||||
<h2>Admin Control</h2>
|
||||
<div id="adminStatus">Checking login status…</div>
|
||||
|
||||
<form id="adminLoginForm" style="display:none;">
|
||||
<input type="password" id="adminPassword" placeholder="Enter admin password" autocomplete="current-password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
<div id="adminLogout" style="display:none;">
|
||||
<button id="logoutBtn">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
11
src/pages/gallery.php
Normal file
11
src/pages/gallery.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// pages/gallery.php – Phase 2 gallery view
|
||||
?>
|
||||
<section id="galleryPage">
|
||||
<h2>Gallery</h2>
|
||||
<div id="galleryContainer" class="gallery-grid"></div>
|
||||
<div id="galleryLoading" class="loading center">Loading…</div>
|
||||
<div id="galleryEnd" class="center hidden">End of gallery</div>
|
||||
</section>
|
||||
<script src="js/gallery.js" defer></script>
|
||||
<link rel="stylesheet" href="css/gallery.css">
|
||||
57
src/pages/home.php
Normal file
57
src/pages/home.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// pages/home.php
|
||||
?>
|
||||
<section id="homePage">
|
||||
<h2>System Overview</h2>
|
||||
<div id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<h3>CPU ⚡</h3>
|
||||
<p id="statCpu">–</p>
|
||||
<div class="progress-bar"><div id="barCpu"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>RAM 🧠</h3>
|
||||
<p id="statRam">–</p>
|
||||
<small id="statRamText">–</small>
|
||||
<div class="progress-bar"><div id="barRam"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>ZRAM 🔄</h3>
|
||||
<p id="statZram">–</p>
|
||||
<div class="progress-bar"><div id="barZram"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Swap 🔄</h3>
|
||||
<p id="statSwap">–</p>
|
||||
<div class="progress-bar"><div id="barSwap"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Disk 0 💾</h3>
|
||||
<p id="statDisk">–</p>
|
||||
<small id="statDiskText">–</small>
|
||||
<div class="progress-bar"><div id="barDisk"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Disk 1 💾</h3>
|
||||
<p id="statDisk0">–</p>
|
||||
<small id="statDisk0Text">–</small>
|
||||
<div class="progress-bar"><div id="barDisk0"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Images 🖼️</h3>
|
||||
<p id="statImages">–</p>
|
||||
<div class="progress-bar"><div id="barImages"></div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Blank Card ✏️</h3>
|
||||
<p id="statImages">–</p>
|
||||
<div class="progress-bar"><div id="barnone"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<link rel="stylesheet" href="css/home.css">
|
||||
<script src="js/home.js" defer></script>
|
||||
|
||||
|
||||
<link rel="stylesheet" href="css/home.css">
|
||||
0
src/pages/shell.php
Normal file
0
src/pages/shell.php
Normal file
26
src/pages/upload.php
Normal file
26
src/pages/upload.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
// pages/upload.php — Phase 3B (with progress bars, fixed for SPA)
|
||||
require_once __DIR__ . '/../includes/config.php';
|
||||
?>
|
||||
<section class="page-section upload-page">
|
||||
<h2>Upload Images</h2>
|
||||
<p class="muted">Drag & drop JPEG files here, or click below to select multiple.</p>
|
||||
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<input type="file" id="fileInput" accept="image/jpeg" multiple hidden>
|
||||
<button id="selectBtn" class="upload-btn">Select Files</button>
|
||||
</div>
|
||||
|
||||
<div id="uploadList" class="upload-list"></div>
|
||||
<div id="uploadStatus" class="upload-status"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Make upload limit available to JS
|
||||
window.UPLOAD_LIMIT = <?= (int)($CONFIG['max_parallel_uploads'] ?? 4) ?>;
|
||||
</script>
|
||||
|
||||
<!-- Include scripts last -->
|
||||
<link rel="stylesheet" href="css/upload.css">
|
||||
<script src="js/upload.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user