First dev version v.0.1

This commit is contained in:
2025-10-21 17:53:37 +01:00
parent 8f319f4039
commit a09d4818b5
26 changed files with 1680 additions and 0 deletions

116
src/css/admin.css Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

1
src/dist/js/glightbox.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View 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.";
}

View 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
View 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
View 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']);

View 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
View 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,
]);

View 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 (1100)
* @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;
}

View 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
View 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
View 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
View 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
View 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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
View 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
View 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
View 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
View 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
View 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
View File

26
src/pages/upload.php Normal file
View 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>